diff --git a/.github/workflows/setup-repo-tests.yml b/.github/workflows/setup-repo-tests.yml index 714a6f5..2222d0f 100644 --- a/.github/workflows/setup-repo-tests.yml +++ b/.github/workflows/setup-repo-tests.yml @@ -30,4 +30,5 @@ jobs: run: | bash tests/setup-repo/test-python-assets.sh bash tests/setup-repo/test-ci-assets.sh + bash tests/setup-repo/test-node-assets.sh bash tests/setup-repo/test-scaffold.sh diff --git a/plugins/core/.claude-plugin/plugin.json b/plugins/core/.claude-plugin/plugin.json index bf7aa0a..68f07ce 100644 --- a/plugins/core/.claude-plugin/plugin.json +++ b/plugins/core/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "core", - "version": "0.3.0", + "version": "0.4.0", "description": "Core Offworld Labs skills, commands, agents, and hooks shared across all repos.", "author": { "name": "Offworld Labs" diff --git a/plugins/core/skills/setup-repo/SKILL.md b/plugins/core/skills/setup-repo/SKILL.md index 4bc3bc4..df217ae 100644 --- a/plugins/core/skills/setup-repo/SKILL.md +++ b/plugins/core/skills/setup-repo/SKILL.md @@ -18,22 +18,26 @@ interactive parts and the report. (`git rev-parse --is-inside-work-tree`). If it is not, offer to run `git init`; abort if the user declines. -2. **Determine the stack.** Detect from existing files: `pyproject.toml` or - `requirements*.txt` → `python`. If ambiguous or empty, ask the user to choose - `python` or `none` (ts-frontend / ts-backend arrive in a later phase). +2. **Determine the stack.** Detect from existing files: `pyproject.toml` / + `requirements*.txt` → `python`; `package.json` / `tsconfig.json` → TypeScript + (then ask whether it's `ts-frontend` — a React/Vite app — or `ts-backend` — a + Node service). If ambiguous or empty, ask the user to choose `python`, + `ts-frontend`, `ts-backend`, or `none`. 3. **Scaffold the files.** Run the engine, which never overwrites existing files: `bash "$ENGINE" . ` Relay its `WRITTEN` / `SKIPPED` output to the user. -4. **Install dependencies.** For `python`, use `uv` (the org standard, a fast - drop-in for pip that reads the same `requirements.txt`): create a venv and - install with - `uv venv && uv pip install -r requirements.txt -r requirements-dev.txt`. - If `uv` isn't installed, fall back to - `pip install -r requirements.txt -r requirements-dev.txt` in an active - virtualenv. Report the command and result; if the toolchain is unavailable, - skip and note it rather than failing. +4. **Install dependencies.** + - **Python:** use `uv` (the org standard, a fast drop-in for pip that reads the + same `requirements.txt`): `uv venv && uv pip install -r requirements.txt -r requirements-dev.txt`. + Fall back to `pip install -r requirements.txt -r requirements-dev.txt` in an + active virtualenv if `uv` is absent. + - **ts-frontend / ts-backend:** run `npm install` (this generates + `package-lock.json` — remind the user to commit it, since the CI workflow uses + `npm ci`). + Report the command and result; if the toolchain is unavailable, skip and note it + rather than failing. 5. **Flesh out CLAUDE.md.** The stub was just written. Ask the user for a one-or-two-line description of what this repo does, then fill in the diff --git a/plugins/core/skills/setup-repo/assets/ci/ci-node.yml b/plugins/core/skills/setup-repo/assets/ci/ci-node.yml new file mode 100644 index 0000000..67ff7ff --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/ci/ci-node.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Test + run: npm test diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-backend/eslint.config.js b/plugins/core/skills/setup-repo/assets/stack/ts-backend/eslint.config.js new file mode 100644 index 0000000..321fe3b --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-backend/eslint.config.js @@ -0,0 +1,8 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist", "node_modules"] }, + js.configs.recommended, + ...tseslint.configs.recommended, +); diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-backend/gitignore b/plugins/core/skills/setup-repo/assets/stack/ts-backend/gitignore new file mode 100644 index 0000000..ea9ae58 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-backend/gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +coverage/ +*.log +.env +.DS_Store diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-backend/package.json b/plugins/core/skills/setup-repo/assets/stack/ts-backend/package.json new file mode 100644 index 0000000..4beb7bc --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-backend/package.json @@ -0,0 +1,22 @@ +{ + "name": "service", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@types/node": "^22.5.0", + "eslint": "^9.9.0", + "tsx": "^4.19.0", + "typescript": "^5.6.2", + "typescript-eslint": "^8.3.0", + "vitest": "^4.1.4" + } +} diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-backend/src/index.test.ts b/plugins/core/skills/setup-repo/assets/stack/ts-backend/src/index.test.ts new file mode 100644 index 0000000..667672e --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-backend/src/index.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from "vitest"; +import { version } from "./index"; + +test("version returns the placeholder", () => { + expect(version()).toBe("0.0.0"); +}); diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-backend/src/index.ts b/plugins/core/skills/setup-repo/assets/stack/ts-backend/src/index.ts new file mode 100644 index 0000000..38f18c1 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-backend/src/index.ts @@ -0,0 +1,3 @@ +export function version(): string { + return "0.0.0"; +} diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-backend/tsconfig.json b/plugins/core/skills/setup-repo/assets/stack/ts-backend/tsconfig.json new file mode 100644 index 0000000..c5ee29b --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-backend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src"] +} diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-backend/vitest.config.ts b/plugins/core/skills/setup-repo/assets/stack/ts-backend/vitest.config.ts new file mode 100644 index 0000000..c55c5cc --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-backend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + }, +}); diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-frontend/eslint.config.js b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/eslint.config.js new file mode 100644 index 0000000..40b03ce --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/eslint.config.js @@ -0,0 +1,14 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; + +export default tseslint.config( + { ignores: ["dist", "node_modules"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + plugins: { "react-hooks": reactHooks }, + rules: { ...reactHooks.configs.recommended.rules }, + }, +); diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-frontend/gitignore b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/gitignore new file mode 100644 index 0000000..ea9ae58 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +coverage/ +*.log +.env +.DS_Store diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-frontend/index.html b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/index.html new file mode 100644 index 0000000..9aefcae --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/index.html @@ -0,0 +1,12 @@ + + + + + + App + + +
+ + + diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-frontend/package.json b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/package.json new file mode 100644 index 0000000..a4ddf95 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0", + "jsdom": "^25.0.0", + "typescript": "^5.6.2", + "typescript-eslint": "^8.3.0", + "vite": "^5.4.2", + "vitest": "^4.1.4" + } +} diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-frontend/src/index.test.ts b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/src/index.test.ts new file mode 100644 index 0000000..667672e --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/src/index.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from "vitest"; +import { version } from "./index"; + +test("version returns the placeholder", () => { + expect(version()).toBe("0.0.0"); +}); diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-frontend/src/index.ts b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/src/index.ts new file mode 100644 index 0000000..38f18c1 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/src/index.ts @@ -0,0 +1,3 @@ +export function version(): string { + return "0.0.0"; +} diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-frontend/src/main.tsx b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/src/main.tsx new file mode 100644 index 0000000..71c0386 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +const root = document.getElementById("root"); +if (root) { + createRoot(root).render( + +

App

+
, + ); +} diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-frontend/tsconfig.json b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/tsconfig.json new file mode 100644 index 0000000..b9a257d --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["vitest/globals"] + }, + "include": ["src"] +} diff --git a/plugins/core/skills/setup-repo/assets/stack/ts-frontend/vite.config.ts b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/vite.config.ts new file mode 100644 index 0000000..ed6088b --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/ts-frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + }, +}); diff --git a/plugins/core/skills/setup-repo/scripts/scaffold-repo.sh b/plugins/core/skills/setup-repo/scripts/scaffold-repo.sh index f0c7452..a96594f 100755 --- a/plugins/core/skills/setup-repo/scripts/scaffold-repo.sh +++ b/plugins/core/skills/setup-repo/scripts/scaffold-repo.sh @@ -2,7 +2,7 @@ # Deterministic file-scaffolding engine for the core:setup-repo skill. # Copies bundled assets into a target repo without clobbering existing files. # Usage: scaffold-repo.sh -# : python | none (ts-frontend / ts-backend land in Phase 2) +# : python | ts-frontend | ts-backend | none set -euo pipefail TARGET="${1:?target dir required}" @@ -10,7 +10,7 @@ STACK="${2:-none}" ASSETS="$(cd "$(dirname "${BASH_SOURCE[0]}")/../assets" && pwd)" case "$STACK" in - python|none) ;; + python|ts-frontend|ts-backend|none) ;; *) echo "unknown stack: $STACK" >&2; exit 2 ;; esac @@ -51,6 +51,28 @@ case "$STACK" in copy "$ASSETS/stack/python/tests/.gitkeep" "$TARGET/tests/.gitkeep" copy "$ASSETS/ci/ci-python.yml" "$TARGET/.github/workflows/ci.yml" ;; + ts-frontend) + copy "$ASSETS/stack/ts-frontend/package.json" "$TARGET/package.json" + copy "$ASSETS/stack/ts-frontend/tsconfig.json" "$TARGET/tsconfig.json" + copy "$ASSETS/stack/ts-frontend/eslint.config.js" "$TARGET/eslint.config.js" + copy "$ASSETS/stack/ts-frontend/vite.config.ts" "$TARGET/vite.config.ts" + copy "$ASSETS/stack/ts-frontend/gitignore" "$TARGET/.gitignore" + copy "$ASSETS/stack/ts-frontend/index.html" "$TARGET/index.html" + copy "$ASSETS/stack/ts-frontend/src/index.ts" "$TARGET/src/index.ts" + copy "$ASSETS/stack/ts-frontend/src/index.test.ts" "$TARGET/src/index.test.ts" + copy "$ASSETS/stack/ts-frontend/src/main.tsx" "$TARGET/src/main.tsx" + copy "$ASSETS/ci/ci-node.yml" "$TARGET/.github/workflows/ci.yml" + ;; + ts-backend) + copy "$ASSETS/stack/ts-backend/package.json" "$TARGET/package.json" + copy "$ASSETS/stack/ts-backend/tsconfig.json" "$TARGET/tsconfig.json" + copy "$ASSETS/stack/ts-backend/eslint.config.js" "$TARGET/eslint.config.js" + copy "$ASSETS/stack/ts-backend/vitest.config.ts" "$TARGET/vitest.config.ts" + copy "$ASSETS/stack/ts-backend/gitignore" "$TARGET/.gitignore" + copy "$ASSETS/stack/ts-backend/src/index.ts" "$TARGET/src/index.ts" + copy "$ASSETS/stack/ts-backend/src/index.test.ts" "$TARGET/src/index.test.ts" + copy "$ASSETS/ci/ci-node.yml" "$TARGET/.github/workflows/ci.yml" + ;; none) ;; esac diff --git a/tests/setup-repo/test-node-assets.sh b/tests/setup-repo/test-node-assets.sh new file mode 100755 index 0000000..e79bd41 --- /dev/null +++ b/tests/setup-repo/test-node-assets.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(git rev-parse --show-toplevel)" +STK="$ROOT/plugins/core/skills/setup-repo/assets/stack" + +check_pkg() { # check_pkg + local dir="$1" needs_react="$2" + python3 - "$dir/package.json" "$needs_react" <<'EOF' +import json, sys +pkg = json.load(open(sys.argv[1])) +needs_react = sys.argv[2] == "1" +scripts = pkg.get("scripts", {}) +for s in ("typecheck", "lint", "test"): + assert s in scripts, f"missing script {s}: {scripts}" +assert scripts["lint"] == "eslint .", scripts +assert scripts["typecheck"] == "tsc --noEmit", scripts +assert scripts["test"] == "vitest run", scripts +dev = pkg.get("devDependencies", {}) +for d in ("eslint", "typescript", "typescript-eslint", "vitest", "@eslint/js"): + assert d in dev, f"missing devDep {d}: {dev}" +if needs_react: + deps = pkg.get("dependencies", {}) + assert "react" in deps and "react-dom" in deps, deps + assert "vite" in dev and "@vitejs/plugin-react" in dev, dev +print(f"{sys.argv[1]} OK") +EOF +} + +check_stack_files() { # check_stack_files + local dir="$1" + for f in package.json tsconfig.json eslint.config.js gitignore \ + src/index.ts src/index.test.ts; do + test -f "$dir/$f" || { echo "MISSING: $dir/$f" >&2; exit 1; } + done + python3 -c "import json; json.load(open('$dir/tsconfig.json'))" + grep -q 'node_modules' "$dir/gitignore" + grep -q 'typescript-eslint' "$dir/eslint.config.js" +} + +check_stack_files "$STK/ts-frontend" +check_pkg "$STK/ts-frontend" 1 +for f in vite.config.ts index.html src/main.tsx; do + test -f "$STK/ts-frontend/$f" || { echo "MISSING: $STK/ts-frontend/$f" >&2; exit 1; } +done +echo "ts-frontend assets OK" + +check_stack_files "$STK/ts-backend" +check_pkg "$STK/ts-backend" 0 +test -f "$STK/ts-backend/vitest.config.ts" || { echo "MISSING: $STK/ts-backend/vitest.config.ts" >&2; exit 1; } +echo "ts-backend assets OK" + +CI="$ROOT/plugins/core/skills/setup-repo/assets/ci/ci-node.yml" +python3 - "$CI" <<'EOF' +import sys +try: + import yaml +except ModuleNotFoundError: + print("pyyaml missing; skipping ci-node YAML parse"); sys.exit(0) +doc = yaml.safe_load(open(sys.argv[1])) +on = doc.get("on", doc.get(True)) +assert on["push"]["branches"] == ["main"], on +assert "pull_request" in on, on +steps = doc["jobs"]["lint-and-test"]["steps"] +uses = [str(s.get("uses", "")) for s in steps] +setup_node = [s for s in steps if str(s.get("uses", "")).startswith("actions/setup-node")] +assert setup_node, uses +assert setup_node[0]["with"]["node-version"] == "20", setup_node +assert setup_node[0]["with"]["cache"] == "npm", setup_node +runs = "\n".join(s.get("run", "") for s in steps) +assert "npm ci" in runs, runs +assert "npm run typecheck" in runs, runs +assert "npm run lint" in runs, runs +assert "npm test" in runs, runs +print("ci-node.yml OK") +EOF +echo "node ci asset OK" diff --git a/tests/setup-repo/test-scaffold.sh b/tests/setup-repo/test-scaffold.sh index 4b3847e..9de14cc 100755 --- a/tests/setup-repo/test-scaffold.sh +++ b/tests/setup-repo/test-scaffold.sh @@ -75,4 +75,34 @@ git -C "$BOGUS" init -q set +e; bash "$ENGINE" "$BOGUS" bogus >/dev/null 2>&1; rc=$?; set -e [ "$rc" -eq 2 ] || { echo "expected exit 2 for unknown stack, got $rc" >&2; exit 1; } [ -z "$(ls -A "$BOGUS" | grep -v '^.git$' || true)" ] || { echo "unknown stack wrote files" >&2; exit 1; } + +# ts-frontend: writes node/ts files + ci.yml, omits python-only files +TMP_TSF="$(mktemp -d)" +trap 'rm -rf "$TMP" "$TMP_NONE" "$BOGUS" "$TMP_TSF"' EXIT +git -C "$TMP_TSF" init -q +bash "$ENGINE" "$TMP_TSF" ts-frontend +for f in .claude/settings.json CLAUDE.md .editorconfig \ + package.json tsconfig.json eslint.config.js vite.config.ts .gitignore \ + index.html src/index.ts src/index.test.ts src/main.tsx .github/workflows/ci.yml \ + .github/workflows/claude.yml; do + test -e "$TMP_TSF/$f" || { echo "MISSING (ts-frontend): $f" >&2; exit 1; } +done +for f in pyproject.toml requirements.txt requirements-dev.txt; do + test -e "$TMP_TSF/$f" && { echo "UNEXPECTED (ts-frontend): $f" >&2; exit 1; } +done +python3 -c "import json; json.load(open('$TMP_TSF/package.json'))" +grep -q '"react"' "$TMP_TSF/package.json" || { echo "ts-frontend missing react" >&2; exit 1; } +grep -q 'npm ci' "$TMP_TSF/.github/workflows/ci.yml" || { echo "ts-frontend ci not node" >&2; exit 1; } + +# ts-backend: same file set, no react +TMP_TSB="$(mktemp -d)" +trap 'rm -rf "$TMP" "$TMP_NONE" "$BOGUS" "$TMP_TSF" "$TMP_TSB"' EXIT +git -C "$TMP_TSB" init -q +bash "$ENGINE" "$TMP_TSB" ts-backend +for f in package.json tsconfig.json eslint.config.js vitest.config.ts .gitignore \ + src/index.ts .github/workflows/ci.yml; do + test -e "$TMP_TSB/$f" || { echo "MISSING (ts-backend): $f" >&2; exit 1; } +done +grep -q '"react"' "$TMP_TSB/package.json" && { echo "ts-backend should not have react" >&2; exit 1; } +python3 -c "import json; json.load(open('$TMP_TSB/package.json'))" echo "ALL CHECKS PASSED"