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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tapestry-cli
3
- Version: 0.1.4
3
+ Version: 0.1.5
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.4"
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
- """loom init — onboard the current directory as a loom consuming project.
1
+ """tapestry init — onboard the current directory as a Tapestry consuming project.
2
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.
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 (mirrors `docs/howto/onboard-a-project.md` Part 1, steps 2-5):
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. 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 your project-registry deployment's /projects endpoint to
14
- register the project (set --registry-url or TAPESTRY_REGISTRY_URL).
15
- Self-host mode: no Bearer token needed; server falls back to
16
- SELF_HOST_TENANT_ID.
17
- 4. Create .env in the current dir with OTel credentials copied from
18
- the-loom's .env (so hook events flow to Grafana tagged with this
19
- new project_id) + LOOM_PROJECT_ID=<slug>.
20
- 5. Create .project-intelligence/ folder per the platform-data-model.
21
- 6. Print confirmation + next-steps.
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` or the
25
- PowerShell scaffolder for that).
26
- - Does NOT install the tapestry-discipline Claude Code plugin.
27
- - Does NOT install skills (Phase 5 SDK install-path future work).
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
- def _read_loom_env(loom_repo: Path) -> dict[str, str]:
57
- """Read the-loom's .env to pull OTel credentials for propagation."""
58
- env_path = loom_repo / ".env"
59
- out: dict[str, str] = {}
60
- if not env_path.is_file():
61
- return out
62
- try:
63
- for line in env_path.read_text(encoding="utf-8").splitlines():
64
- line = line.strip()
65
- if not line or line.startswith("#") or "=" not in line:
66
- continue
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, loom_env: dict[str, str]) -> None:
166
- """Create .env with OTel propagation + LOOM_PROJECT_ID. Does NOT overwrite."""
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 of the-loom.",
175
- "# Generated by `loom init` — review before using.",
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
- "# OTel credentials propagated from the-loom/.env so this project's",
181
- "# hook events flow to the same Grafana Cloud stack, tagged with the",
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
- for key in (
185
- "OTEL_EXPORTER_OTLP_ENDPOINT",
186
- "OTEL_EXPORTER_OTLP_PROTOCOL",
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
- # Locate the-loom repo
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/4] Check Project Registry for existing slug '{args.slug}'...")
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/4] Register '{args.slug}' with the Project Registry...")
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
- loom_env = _read_loom_env(loom_repo)
429
- if not loom_env.get("OTEL_EXPORTER_OTLP_HEADERS"):
430
- print(f" WARN: {loom_repo}/.env didn't have OTEL_EXPORTER_OTLP_HEADERS;")
431
- print(f" the generated .env won't have telemetry credentials. Fix manually.")
432
-
433
- print(f"--> [3/5] Create .env in {project_dir}...")
434
- _write_env_file(project_dir, args.slug, loom_env)
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
- if not _gitignore_has_env(project_dir):
437
- print(f" WARN: .gitignore does NOT contain '.env'. Add it to prevent")
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/5] Wire loom-memory MCP server (concrete-rule Layer 5)...")
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/5] Initialize .project-intelligence/...")
444
- _write_project_intelligence(project_dir, args.slug, project_uuid or "unknown", name)
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 now registered with the-loom.")
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-discipline plugin installed,")
453
- print(f" see docs/howto/onboard-a-project.md Part 2.")
454
- print(f" 2. Start a Claude Code session in this directory. The SessionStart")
455
- print(f" hook (v0.1.7+) will auto-recall relevant memories for this project.")
456
- print(f" 3. Hook events from this project will flow to Grafana tagged with")
457
- print(f" project_id={args.slug}.")
458
- print(f" 4. If .env doesn't exist or is missing OTel creds, copy from")
459
- print(f" {loom_repo}/.env manually.")
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tapestry-cli
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Tapestry command-line interface — onboard projects, inspect state, open the Observatory.
5
5
  Author: Liz Osborn
6
6
  License: MIT
@@ -11,4 +11,5 @@ tapestry_cli.egg-info/PKG-INFO
11
11
  tapestry_cli.egg-info/SOURCES.txt
12
12
  tapestry_cli.egg-info/dependency_links.txt
13
13
  tapestry_cli.egg-info/entry_points.txt
14
- tapestry_cli.egg-info/top_level.txt
14
+ tapestry_cli.egg-info/top_level.txt
15
+ tests/test_init_seed.py
@@ -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