Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,43 @@ Earlier releases (v0.1.x) are documented in the

## [Unreleased]

### Fixed — `tinkerdown cli` reaches WASM sources; wasm docs use the real config keys

`tinkerdown cli` now constructs `wasm` sources, so a WASM source has the same CRUD
surface on the CLI as under `serve`: `list` works for any module, and
`add`/`update`/`delete` work when the module exports `write` (a read-only module
refuses a write with a clear error rather than dropping it). `exec` remains
intentionally out of the CLI CRUD surface (it is a read-oriented, `--allow-exec`-gated
command runner), now documented in the code rather than a silent fallthrough. The
WASM source docs and the `wasm-source` template were also corrected: they used a
non-existent `module:` key (the real key is `path:`) and `config:` (real: `options:`),
so following them failed to load — plus a new *Read-only vs. writable* section
documents the `write`-export contract and the CLI parity.

### Added — a saved-skills gallery (`examples/gallery/`)

A discoverable home for captured workflows: the gallery is itself a plain
Tinkerdown app (an `lvt-source` over a read-only CSV, no custom JavaScript) that
lists each captured skill with what it stands up and where its stand-up steps
live. Because no source type reads a directory of `SKILL.md` frontmatter, the
gallery renders a committed static index (`skills.csv`) rather than scanning
`skills/` live; a test (`TestSavedSkillsGalleryInSync`) keeps that index in step
with the captured skills on disk — every `skills/` dir except the
framework-authoring skills — so a new capture cannot silently go unlisted.

### Changed — a saved skill now carries its house style

`/tinkerdown:save` captures the manifest **whole**, not trimmed to sources +
actions: the `styling` block (theme + design `tokens`) and any
`generation.style_guide` → `style-guide.md` travel with the capture, under the
same bundle-or-point rule as `seed.sql` / `app.md`. So a saved workflow re-runs
**on-brand** instead of falling back to bare defaults — closing the gap where a
capture kept a console's data surface but dropped its palette. The PII reference
skill demonstrates the point case: it points at the committed
`examples/pii-access-approval/`, whose manifest carries the house style, and a
test now guards that the style file travels and the manifest still round-trips
its `styling.tokens` + `generation.style_guide`.

### Changed — the generation skill now consumes the house style

The `/tinkerdown` generation skill reads a project's house style and authors
Expand Down
73 changes: 67 additions & 6 deletions captured_skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,50 @@ package tinkerdown_test

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/livetemplate/tinkerdown/internal/config"
)

// TestCapturedSkillsWellFormed checks the M1 Phase 6 skills — the captured
// pii-access-approval workflow and the /tinkerdown:save capability — are
// well-formed: valid frontmatter, and every repo path they point at exists.
// TestCapturedSkillsWellFormed checks the captured skills — the pii-access-approval
// workflow (M1 Phase 6) and the /tinkerdown:save capability — are well-formed: valid
// frontmatter, every repo path they point at exists, and (for a skill that points at a
// committed manifest) that manifest carries the house style the app ran on, so the
// saved workflow re-runs on-brand (M5 Phase 1 — capture completeness).
//
// The pii-access-approval skill deliberately POINTS at examples/pii-access-approval/
// rather than copying it (the artifacts are a committed source of truth; a copy
// would be drift waiting to happen). This test is what makes that pointer safe: if
// the example is moved or renamed, the skill's dead reference fails here.
// rather than copying it (the artifacts are a committed source of truth; a copy would
// be drift waiting to happen). This test is what makes that pointer safe: if the
// example is moved, renamed, or loses its style file, the skill's dead reference fails
// here.
//
// The manifestDir round-trip below is real-state (config.LoadFromDir, not a SKILL.md
// substring). It overlaps TestPIIManifestHouseStyleConsumable by design — it asserts
// the same fact (the demo manifest exposes styling.tokens + generation.style_guide) but
// from the *capture* angle: a saved skill re-runs on-brand only if the manifest it
// points at still carries the style. The fixture path is a hardcoded constant, matching
// the refs below; it is NOT scraped out of the SKILL.md prose (that would be a
// self-certifying guard — a string re-asserting a string read from the same prose).
func TestCapturedSkillsWellFormed(t *testing.T) {
skills := []struct {
path string
refs []string // repo-relative paths the skill references, which must exist
// manifestDir, if set, is the committed manifest the skill points at; its
// house style (styling.tokens + generation.style_guide) must round-trip.
manifestDir string
}{
{
path: "skills/pii-access-approval/SKILL.md",
refs: []string{
"examples/pii-access-approval/tinkerdown.yaml",
"examples/pii-access-approval/app.md",
"examples/pii-access-approval/seed.sql",
"examples/pii-access-approval/style-guide.md",
"examples/pii-access-approval/README.md",
},
manifestDir: "examples/pii-access-approval",
},
{
path: "skills/tinkerdown-save/SKILL.md",
Expand Down Expand Up @@ -57,5 +76,47 @@ func TestCapturedSkillsWellFormed(t *testing.T) {
t.Errorf("%s references %q, which does not exist: %v", s.path, ref, err)
}
}

if s.manifestDir == "" {
continue
}
// Capture completeness: the manifest the saved skill runs against must still
// carry the house style, or the workflow re-runs off-brand on defaults.
cfg, err := config.LoadFromDir(s.manifestDir)
if err != nil {
t.Errorf("%s: load manifest %q: %v", s.path, s.manifestDir, err)
continue
}
if len(cfg.Styling.Tokens) == 0 {
t.Errorf("%s: manifest %q carries no styling.tokens — saved workflow would re-run off-brand",
s.path, s.manifestDir)
}
if cfg.Generation == nil || cfg.Generation.StyleGuide == "" {
t.Errorf("%s: manifest %q has no generation.style_guide — house-style prose does not travel",
s.path, s.manifestDir)
continue
}
guidePath := filepath.Join(s.manifestDir, cfg.Generation.StyleGuide)
if _, err := os.Stat(guidePath); err != nil {
t.Errorf("%s: generation.style_guide %q does not resolve to a file (%s): %v",
s.path, cfg.Generation.StyleGuide, guidePath, err)
}
}
}

// TestSaveSkillCapturesHouseStyle pins the M5 Phase 1 deliverable itself: the
// /tinkerdown:save instructions must tell a capture to carry the house style, not just
// sources + actions. This is a structural mention-check (in the spirit of
// TestSkillWiresHouseStyle) — it is the only guard on the prose edit, since the
// round-trip above guards the *example artifacts*, not the *instructions*. It does not,
// and cannot, prove a future LLM capture obeys; it only fails loudly if the instruction
// is reverted so the omission returns silently.
func TestSaveSkillCapturesHouseStyle(t *testing.T) {
const savePath = "skills/tinkerdown-save/SKILL.md"
doc := readDocFile(t, savePath)
for _, want := range []string{"styling", "style_guide", "style-guide.md", "on-brand"} {
if !strings.Contains(doc, want) {
t.Errorf("%s does not instruct capturing the house style: missing %q", savePath, want)
}
}
}
20 changes: 15 additions & 5 deletions cmd/tinkerdown/commands/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/livetemplate/tinkerdown/internal/config"
"github.com/livetemplate/tinkerdown/internal/source"
"github.com/livetemplate/tinkerdown/internal/wasm"
)

// defaultTimeout is the default context timeout for CLI operations
Expand Down Expand Up @@ -126,11 +127,11 @@ func CLICommand(args []string) error {

// cliOptions holds parsed command-line flags
type cliOptions struct {
format string // Output format: table, json, csv
filter string // Filter expression: field=value
id string // Item ID for update/delete
yes bool // Skip confirmation
fields map[string]interface{} // Field values for add/update
format string // Output format: table, json, csv
filter string // Filter expression: field=value
id string // Item ID for update/delete
yes bool // Skip confirmation
fields map[string]interface{} // Field values for add/update
}

// parseFlags parses command-line flags
Expand Down Expand Up @@ -234,6 +235,15 @@ func createCLISource(name string, cfg config.SourceConfig, siteDir, currentFile
return source.NewPostgresSourceWithConfig(name, cfg.Query, cfg.Options, cfg)
case "graphql":
return source.NewGraphQLSource(name, cfg, siteDir)
case "wasm":
// WASM has full CLI parity with `serve`: a module that exports `write` gets
// add/update/delete, and a read-only one (no `write` export) lists only —
// NewWasmSource derives writability from the module (WasmSource.IsReadonly),
// so the cliAdd/cliUpdate/cliDelete readonly guard applies uniformly.
return wasm.NewWasmSource(name, cfg.Path, siteDir, cfg.Options)
// exec is intentionally not constructed here: it is a read-oriented command
// runner gated by --allow-exec, not a CRUD store, so it has no place in the
// `tinkerdown cli` add/update/delete surface. This omission is deliberate.
default:
return nil, fmt.Errorf("unsupported source type for CLI: %s", cfg.Type)
}
Expand Down
64 changes: 64 additions & 0 deletions cmd/tinkerdown/commands/cli_wasmsource_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package commands

import (
"context"
"strings"
"testing"

"github.com/livetemplate/tinkerdown/internal/config"
"github.com/livetemplate/tinkerdown/internal/source"
)

// TestCLIWasmSourceReadParityAndReadonlyGuard verifies the wasm CLI parity added in
// M5 Phase 3: createCLISource now constructs a wasm source (it previously fell through
// to "unsupported source type for CLI"). It exercises the whole surface that IS
// verifiable in a TinyGo-free environment, against the committed read-only quotes
// module:
// - the source constructs and Fetches — the read path actually reaches the CLI; and
// - the readonly guard holds: a module with no `write` export reports IsReadonly()
// true, and WriteItem refuses with a guard error rather than silently dropping a
// write.
//
// A *successful* write needs a module that exports `write`, which requires TinyGo to
// build (absent here). The only committed .wasm is read-only, and no test in any
// transport — CLI or WebSocket — exercises a successful write. That gap is tracked as
// a follow-up (see the plan's M5 Phase 3 Learn / #216). Do not "verify" what cannot be
// run.
func TestCLIWasmSourceReadParityAndReadonlyGuard(t *testing.T) {
const wasmDir = "../../../examples/lvt-source-wasm-test"
cfg := config.SourceConfig{
Type: "wasm",
Path: "./sources/quotes.wasm",
Options: map[string]string{"category": "inspiration"},
}

src, err := createCLISource("quotes", cfg, wasmDir, "")
if err != nil {
t.Fatalf("createCLISource(wasm) — the parity gap this phase closes: %v", err)
}
defer src.Close()

ctx := context.Background()
rows, err := src.Fetch(ctx)
if err != nil {
t.Fatalf("wasm Fetch (read parity): %v", err)
}
if len(rows) == 0 {
t.Error("wasm source returned no rows — the read path is not actually running")
}

writable, ok := src.(source.WritableSource)
if !ok {
t.Fatal("wasm source does not implement WritableSource")
}
if !writable.IsReadonly() {
t.Error("the quotes module exports no `write`, so IsReadonly() should report true")
}
err = writable.WriteItem(ctx, "add", map[string]interface{}{"text": "x"})
if err == nil {
t.Fatal("WriteItem on a read-only module must error, not silently succeed")
}
if !strings.Contains(err.Error(), "does not support write") {
t.Errorf("WriteItem error = %q, want the 'does not support write operations' guard", err)
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
sources:
data:
type: wasm
module: ../source.wasm
path: ../source.wasm
Loading
Loading