odd-studio 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env bash
2
+ # odd-git-safety.sh
3
+ # ODD Studio — Git Safety Hook (PreToolUse: Bash)
4
+ #
5
+ # Intercepts dangerous git commands before they execute.
6
+ # Protects against force-push, hard-reset, and branch deletion
7
+ # that could destroy work in progress.
8
+ #
9
+ # Exit 0 → allow the command
10
+ # Exit 2 → block the command (Claude will not execute it)
11
+ # Stderr → message shown to the user
12
+
13
+ COMMAND="${CLAUDE_TOOL_INPUT:-}"
14
+
15
+ # ── Helper ────────────────────────────────────────────────────────────────────
16
+ block() {
17
+ echo "🔒 ODD GIT SAFETY BLOCK: $1" >&2
18
+ echo "" >&2
19
+ echo "$2" >&2
20
+ echo "" >&2
21
+ echo "To override: commit your work first, then re-run with explicit intent." >&2
22
+ exit 2
23
+ }
24
+
25
+ warn() {
26
+ echo "⚠️ ODD GIT SAFETY WARNING: $1" >&2
27
+ echo "$2" >&2
28
+ # Warnings don't block — they inform
29
+ }
30
+
31
+ # ── Only check git commands ───────────────────────────────────────────────────
32
+ if ! echo "$COMMAND" | grep -qE '^\s*(git\s)'; then
33
+ exit 0
34
+ fi
35
+
36
+ # ── Force push ────────────────────────────────────────────────────────────────
37
+ if echo "$COMMAND" | grep -qE 'git\s+push.*(--force|-f)(\s|$)'; then
38
+ block \
39
+ "Force push detected." \
40
+ "Force-pushing rewrites remote history and can permanently destroy teammates' work.
41
+ ODD Studio never force-pushes. If you need to update a remote branch:
42
+ - Use: git push --force-with-lease (safer — fails if remote has new commits)
43
+ - Or create a new branch for your changes"
44
+ fi
45
+
46
+ # ── Force push to main/master ─────────────────────────────────────────────────
47
+ if echo "$COMMAND" | grep -qE 'git\s+push.*--force-with-lease.*(main|master)'; then
48
+ block \
49
+ "Force push to main/master detected." \
50
+ "ODD Studio never force-pushes to the main branch.
51
+ This is a non-reversible operation on your primary branch.
52
+ Create a PR and merge instead."
53
+ fi
54
+
55
+ # ── Hard reset ────────────────────────────────────────────────────────────────
56
+ if echo "$COMMAND" | grep -qE 'git\s+reset\s+--hard'; then
57
+ # Check if there are uncommitted changes first
58
+ if git diff --quiet 2>/dev/null && git diff --cached --quiet 2>/dev/null; then
59
+ # Working tree is clean — allow hard reset
60
+ exit 0
61
+ else
62
+ block \
63
+ "Hard reset with uncommitted changes detected." \
64
+ "You have uncommitted changes that would be permanently lost.
65
+ ODD Studio blocked this to protect your work.
66
+ Options:
67
+ - git stash (save changes temporarily)
68
+ - git commit -am 'WIP: saving before reset' (commit first)
69
+ - git diff (review what would be lost)"
70
+ fi
71
+ fi
72
+
73
+ # ── Discard all changes ───────────────────────────────────────────────────────
74
+ if echo "$COMMAND" | grep -qE 'git\s+checkout\s+--?\s*\.'; then
75
+ block \
76
+ "Discard all changes (git checkout -- .) detected." \
77
+ "This would permanently discard all uncommitted changes in your working tree.
78
+ ODD Studio blocked this to protect your work.
79
+ Options:
80
+ - git stash (save changes temporarily)
81
+ - git diff (review what would be lost)
82
+ - git commit -am 'WIP' (commit first)"
83
+ fi
84
+
85
+ # ── git restore all ───────────────────────────────────────────────────────────
86
+ if echo "$COMMAND" | grep -qE 'git\s+restore\s+\.'; then
87
+ block \
88
+ "Restore all files (git restore .) detected." \
89
+ "This would permanently discard all unstaged changes.
90
+ Commit or stash your work first."
91
+ fi
92
+
93
+ # ── git clean ─────────────────────────────────────────────────────────────────
94
+ if echo "$COMMAND" | grep -qE 'git\s+clean\s+.*-[a-zA-Z]*f'; then
95
+ block \
96
+ "git clean -f detected." \
97
+ "This permanently deletes untracked files with no recovery path.
98
+ ODD Studio blocked this.
99
+ Use git status to review untracked files before deleting them."
100
+ fi
101
+
102
+ # ── Force branch delete ───────────────────────────────────────────────────────
103
+ if echo "$COMMAND" | grep -qE 'git\s+branch\s+(-D|--delete --force)'; then
104
+ BRANCH=$(echo "$COMMAND" | grep -oE '(-D|--delete --force)\s+\S+' | awk '{print $2}')
105
+ warn \
106
+ "Force branch deletion: $BRANCH" \
107
+ "If this branch has unmerged commits they will be lost.
108
+ Verify the branch is fully merged before deleting."
109
+ # Warning only — does not block. Claude will proceed but user is informed.
110
+ fi
111
+
112
+ # ── Amend published commits ───────────────────────────────────────────────────
113
+ if echo "$COMMAND" | grep -qE 'git\s+commit.*--amend'; then
114
+ # Check if HEAD is pushed to a remote
115
+ if git log --oneline "@{u}..HEAD" 2>/dev/null | grep -q .; then
116
+ # HEAD has commits not on remote — amend is safe
117
+ exit 0
118
+ elif git rev-parse "@{u}" >/dev/null 2>&1; then
119
+ block \
120
+ "Amending a published commit." \
121
+ "This commit has been pushed to the remote.
122
+ Amending it will require a force-push, which rewrites history.
123
+ Instead: create a new commit with your changes."
124
+ fi
125
+ fi
126
+
127
+ # ── Skip hooks ────────────────────────────────────────────────────────────────
128
+ if echo "$COMMAND" | grep -qE '(--no-verify|-n)\s'; then
129
+ block \
130
+ "--no-verify flag detected." \
131
+ "ODD Studio never skips pre-commit hooks (--no-verify).
132
+ Hooks exist to protect build quality. If a hook is failing:
133
+ 1. Read the error carefully
134
+ 2. Fix the underlying issue
135
+ 3. Commit cleanly"
136
+ fi
137
+
138
+ exit 0
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # odd-outcome-quality.sh
3
+ # ODD Studio — Outcome Quality Gate (PostToolUse: Write)
4
+ #
5
+ # Runs after a file is written. If the file is in docs/outcomes/ or docs/personas/,
6
+ # checks for completeness. Provides coaching — does not block.
7
+
8
+ FILE_PATH="${CLAUDE_TOOL_RESULT_FILE:-}"
9
+
10
+ # ── Only check ODD docs ───────────────────────────────────────────────────────
11
+ if ! echo "$FILE_PATH" | grep -qE 'docs/(outcomes|personas)/'; then
12
+ exit 0
13
+ fi
14
+
15
+ if [ ! -f "$FILE_PATH" ]; then
16
+ exit 0
17
+ fi
18
+
19
+ CONTENT=$(cat "$FILE_PATH")
20
+
21
+ # ── Outcome quality check ─────────────────────────────────────────────────────
22
+ if echo "$FILE_PATH" | grep -q 'docs/outcomes/'; then
23
+ MISSING=""
24
+
25
+ # Check for all six fields
26
+ echo "$CONTENT" | grep -qiE 'Field 1.*Persona|## Persona' || MISSING="$MISSING\n • Field 1 (Persona) — not found"
27
+ echo "$CONTENT" | grep -qiE 'Field 2.*Trigger|## Trigger' || MISSING="$MISSING\n • Field 2 (Trigger) — not found"
28
+ echo "$CONTENT" | grep -qiE 'Field 3.*Walkthrough|## Walkthrough' || MISSING="$MISSING\n • Field 3 (Walkthrough) — not found"
29
+ echo "$CONTENT" | grep -qiE 'Field 4.*Verif|## Verif' || MISSING="$MISSING\n • Field 4 (Verification) — not found"
30
+ echo "$CONTENT" | grep -qiE 'Field 5.*Contracts|## Contracts' || MISSING="$MISSING\n • Field 5 (Contracts Exposed) — not found"
31
+ echo "$CONTENT" | grep -qiE 'Field 6.*Dep|## Dep' || MISSING="$MISSING\n • Field 6 (Dependencies) — not found"
32
+
33
+ # Check for banned vocabulary
34
+ BANNED=$(echo "$CONTENT" | grep -iE '\b(user story|user stories|sprint|epic|backlog|api endpoint|database schema|database table|json payload|http request)\b' | head -5)
35
+
36
+ if [ -n "$MISSING" ] || [ -n "$BANNED" ]; then
37
+ echo "" >&2
38
+ echo "🎯 ODD OUTCOME QUALITY CHECK: $(basename "$FILE_PATH")" >&2
39
+ if [ -n "$MISSING" ]; then
40
+ echo "" >&2
41
+ echo "Incomplete outcome — missing fields:" >&2
42
+ echo -e "$MISSING" >&2
43
+ echo "" >&2
44
+ echo "An outcome with missing fields will produce an incomplete build." >&2
45
+ echo "Run *review-outcome in /odd to fix before proceeding." >&2
46
+ fi
47
+ if [ -n "$BANNED" ]; then
48
+ echo "" >&2
49
+ echo "Banned vocabulary detected:" >&2
50
+ echo "$BANNED" | while IFS= read -r line; do echo " → $line" >&2; done
51
+ echo "" >&2
52
+ echo "ODD outcomes must be written in domain language only." >&2
53
+ echo "Translate technical terms back to what the user experiences." >&2
54
+ fi
55
+ echo "" >&2
56
+ fi
57
+ fi
58
+
59
+ # ── Persona quality check ─────────────────────────────────────────────────────
60
+ if echo "$FILE_PATH" | grep -q 'docs/personas/'; then
61
+ MISSING_DIMS=""
62
+
63
+ echo "$CONTENT" | grep -qiE '## Identity|identity' || MISSING_DIMS="$MISSING_DIMS\n • Identity dimension"
64
+ echo "$CONTENT" | grep -qiE '## Current Reality|current.?reality' || MISSING_DIMS="$MISSING_DIMS\n • Current Reality dimension"
65
+ echo "$CONTENT" | grep -qiE '## Technical Context|technical.?context' || MISSING_DIMS="$MISSING_DIMS\n • Technical Context dimension"
66
+ echo "$CONTENT" | grep -qiE '## Constraints|constraints' || MISSING_DIMS="$MISSING_DIMS\n • Constraints dimension"
67
+ echo "$CONTENT" | grep -qiE '## Trigger Patterns|trigger.?patterns' || MISSING_DIMS="$MISSING_DIMS\n • Trigger Patterns dimension"
68
+ echo "$CONTENT" | grep -qiE '## Success Definition|success.?definition' || MISSING_DIMS="$MISSING_DIMS\n • Success Definition dimension"
69
+ echo "$CONTENT" | grep -qiE '## Failure Tolerance|failure.?tolerance' || MISSING_DIMS="$MISSING_DIMS\n • Failure Tolerance dimension"
70
+
71
+ if [ -n "$MISSING_DIMS" ]; then
72
+ echo "" >&2
73
+ echo "👤 ODD PERSONA QUALITY CHECK: $(basename "$FILE_PATH")" >&2
74
+ echo "" >&2
75
+ echo "Thin persona — missing dimensions:" >&2
76
+ echo -e "$MISSING_DIMS" >&2
77
+ echo "" >&2
78
+ echo "A persona without all 7 dimensions produces outcomes designed for nobody in particular." >&2
79
+ echo "Run *review-persona in /odd to complete it." >&2
80
+ echo "" >&2
81
+ fi
82
+ fi
83
+
84
+ exit 0
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ # odd-pre-build.sh
3
+ # ODD Studio — Pre-Build Safety Check (PreToolUse: Bash)
4
+ #
5
+ # Runs before npm run build, npm test, and deployment commands.
6
+ # Checks that ODD project state is sane before the build proceeds.
7
+
8
+ COMMAND="${CLAUDE_TOOL_INPUT:-}"
9
+
10
+ # ── Only intercept build/deploy commands ─────────────────────────────────────
11
+ if ! echo "$COMMAND" | grep -qE '(npm\s+run\s+(build|deploy|start:prod)|yarn\s+(build|deploy)|pnpm\s+(build|deploy))'; then
12
+ exit 0
13
+ fi
14
+
15
+ # ── Check git is clean before production builds ───────────────────────────────
16
+ if echo "$COMMAND" | grep -qiE '(deploy|start:prod)'; then
17
+ if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then
18
+ echo "🏗️ ODD PRE-BUILD: Uncommitted changes before deploy." >&2
19
+ echo "" >&2
20
+ echo "You have uncommitted changes. ODD Studio recommends:" >&2
21
+ echo " 1. Review changes: git diff" >&2
22
+ echo " 2. Commit: git add -A && git commit -m 'describe what changed'" >&2
23
+ echo " 3. Then deploy" >&2
24
+ echo "" >&2
25
+ echo "Deploying from a dirty working tree makes it hard to roll back." >&2
26
+ exit 2
27
+ fi
28
+ fi
29
+
30
+ # ── Check ODD plan exists before first build ─────────────────────────────────
31
+ if [ ! -f "docs/plan.md" ]; then
32
+ echo "🏗️ ODD PRE-BUILD: No Master Implementation Plan found." >&2
33
+ echo "" >&2
34
+ echo "docs/plan.md does not exist in this project." >&2
35
+ echo "ODD Studio recommends completing planning before building:" >&2
36
+ echo " Open Claude Code → /odd → *plan" >&2
37
+ echo "" >&2
38
+ echo "Building without a plan means building the wrong thing." >&2
39
+ # Warning only for missing plan — don't block a legitimate build
40
+ fi
41
+
42
+ # ── Check for any unverified outcomes before building next phase ──────────────
43
+ if [ -d "docs/outcomes" ]; then
44
+ UNVERIFIED=$(grep -rl "status.*draft\|Review Status.*DRAFT" docs/outcomes/ 2>/dev/null | wc -l | tr -d ' ')
45
+ if [ "$UNVERIFIED" -gt 0 ]; then
46
+ echo "🏗️ ODD PRE-BUILD: $UNVERIFIED unverified outcome(s) detected." >&2
47
+ echo "" >&2
48
+ echo "Some outcomes are in DRAFT status and have not been reviewed." >&2
49
+ echo "Building from unreviewed outcomes produces systems that do not match your intentions." >&2
50
+ echo "" >&2
51
+ echo "Run *review-outcome in /odd for each draft outcome before building." >&2
52
+ echo "" >&2
53
+ # Warning only — does not block the build
54
+ fi
55
+ fi
56
+
57
+ exit 0
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ # odd-session-save.sh
3
+ # ODD Studio — Session State Save (PostToolUse: Bash)
4
+ #
5
+ # After git commit commands, syncs project state to ruflo memory
6
+ # so that the next /odd session can resume from exactly this point.
7
+ # Falls back to local .odd/state.json if ruflo is unavailable.
8
+
9
+ COMMAND="${CLAUDE_TOOL_INPUT:-}"
10
+
11
+ # ── Only trigger on git commit ────────────────────────────────────────────────
12
+ if ! echo "$COMMAND" | grep -qE 'git\s+commit'; then
13
+ exit 0
14
+ fi
15
+
16
+ STATE_FILE=".odd/state.json"
17
+
18
+ if [ ! -f "$STATE_FILE" ]; then
19
+ exit 0
20
+ fi
21
+
22
+ # ── Update last-commit timestamp in local state ───────────────────────────────
23
+ LAST_COMMIT=$(git log -1 --format="%H %s" 2>/dev/null || echo "unknown")
24
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
25
+
26
+ # Use node to update the JSON cleanly (available since odd-studio requires node 18+)
27
+ node -e "
28
+ const fs = require('fs');
29
+ const state = JSON.parse(fs.readFileSync('$STATE_FILE', 'utf8'));
30
+ state.lastCommit = '$LAST_COMMIT';
31
+ state.lastSaved = '$TIMESTAMP';
32
+ fs.writeFileSync('$STATE_FILE', JSON.stringify(state, null, 2));
33
+ console.log('state updated');
34
+ " 2>/dev/null
35
+
36
+ echo "💾 ODD session state saved. Resume with /odd in Claude Code." >&2
37
+
38
+ exit 0
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ # odd-ui-check.sh
3
+ # ODD Studio — UI Quality Reminder (PostToolUse: Edit)
4
+ #
5
+ # After editing UI/frontend files, surfaces accessibility and
6
+ # mobile-first reminders. Educational, not blocking.
7
+
8
+ FILE_PATH="${CLAUDE_TOOL_RESULT_FILE:-}"
9
+
10
+ # ── Only check frontend files ─────────────────────────────────────────────────
11
+ if ! echo "$FILE_PATH" | grep -qiE '\.(tsx|jsx|html|svelte|vue)$'; then
12
+ exit 0
13
+ fi
14
+
15
+ # Only run ~20% of the time to avoid noise (sample by PID)
16
+ if [ $(( $$ % 5 )) -ne 0 ]; then
17
+ exit 0
18
+ fi
19
+
20
+ CONTENT=""
21
+ if [ -f "$FILE_PATH" ]; then
22
+ CONTENT=$(cat "$FILE_PATH")
23
+ fi
24
+
25
+ ISSUES=""
26
+
27
+ # ── Accessibility checks ──────────────────────────────────────────────────────
28
+
29
+ # img without alt
30
+ if echo "$CONTENT" | grep -qE '<img[^>]+(?!alt=)'; then
31
+ if echo "$CONTENT" | grep -qE '<img' && ! echo "$CONTENT" | grep -qE '<img[^>]+alt='; then
32
+ ISSUES="$ISSUES\n • <img> elements should have alt= attributes (screen reader accessibility)"
33
+ fi
34
+ fi
35
+
36
+ # Buttons without accessible text
37
+ if echo "$CONTENT" | grep -qE '<button[^>]*>\s*<'; then
38
+ ISSUES="$ISSUES\n • Buttons containing only icons need aria-label= for screen readers"
39
+ fi
40
+
41
+ # Interactive elements without focus management
42
+ if echo "$CONTENT" | grep -qE 'onClick|onPress' && ! echo "$CONTENT" | grep -qE '(focus|tabIndex|onKeyDown|onKeyPress|role=)'; then
43
+ ISSUES="$ISSUES\n • Interactive elements need keyboard support (onKeyDown or role= + tabIndex)"
44
+ fi
45
+
46
+ # Hardcoded colours instead of tokens
47
+ if echo "$CONTENT" | grep -qE '(style=.*color:\s*#|style=.*background:\s*#)'; then
48
+ ISSUES="$ISSUES\n • Hardcoded colours detected — use Tailwind tokens or CSS variables for theme consistency"
49
+ fi
50
+
51
+ # Non-responsive fixed widths
52
+ if echo "$CONTENT" | grep -qE 'width:\s*[0-9]{3,}px'; then
53
+ ISSUES="$ISSUES\n • Fixed pixel widths may break on mobile — prefer Tailwind responsive classes (w-full, max-w-*)"
54
+ fi
55
+
56
+ # ── Output coaching ───────────────────────────────────────────────────────────
57
+ if [ -n "$ISSUES" ]; then
58
+ echo "" >&2
59
+ echo "🎨 ODD UI QUALITY REMINDER: $(basename "$FILE_PATH")" >&2
60
+ echo "" >&2
61
+ echo "Things to check before marking this UI outcome verified:" >&2
62
+ echo -e "$ISSUES" >&2
63
+ echo "" >&2
64
+ echo "Remember: every UI outcome must be verified on a mobile screen before it passes." >&2
65
+ echo "Resize your browser to 375px wide and walk through your verification steps." >&2
66
+ echo "" >&2
67
+ fi
68
+
69
+ exit 0
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "odd-studio",
3
+ "version": "1.0.0",
4
+ "description": "Outcome-Driven Development for Claude Code — a planning and build harness for domain experts building serious software with AI.",
5
+ "keywords": [
6
+ "claude-code",
7
+ "odd",
8
+ "outcome-driven-development",
9
+ "ai-development",
10
+ "no-code",
11
+ "claude",
12
+ "anthropic"
13
+ ],
14
+ "author": "ODD Studio",
15
+ "license": "MIT",
16
+ "bin": {
17
+ "odd-studio": "./bin/odd-studio.js"
18
+ },
19
+ "scripts": {
20
+ "prepare": "chmod +x bin/odd-studio.js hooks/*.sh",
21
+ "prepublishOnly": "npm run prepare",
22
+ "postinstall": "node scripts/postinstall.js"
23
+ },
24
+ "type": "module",
25
+ "files": [
26
+ "bin/",
27
+ "scripts/",
28
+ "skill/",
29
+ "hooks/",
30
+ "templates/",
31
+ ".claude-plugin/"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ },
36
+ "dependencies": {
37
+ "commander": "^12.0.0",
38
+ "chalk": "^5.3.0",
39
+ "ora": "^8.0.1",
40
+ "inquirer": "^10.0.0",
41
+ "fs-extra": "^11.2.0"
42
+ }
43
+ }
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ /**
7
+ * Installs the /odd skill into ~/.claude/skills/odd/
8
+ * Copies skill/ directory from the package root.
9
+ */
10
+ export default async function installSkill(packageRoot, options = {}) {
11
+ const source = path.join(packageRoot, 'skill');
12
+ const destination = path.join(os.homedir(), '.claude', 'skills', 'odd');
13
+
14
+ if (!fs.existsSync(source)) {
15
+ throw new Error(`Skill source not found at ${source}`);
16
+ }
17
+
18
+ // Ensure ~/.claude/skills/ exists
19
+ await fs.ensureDir(path.join(os.homedir(), '.claude', 'skills'));
20
+
21
+ // If destination exists and not forcing, back it up
22
+ if (fs.existsSync(destination) && !options.force) {
23
+ const backup = destination + '.backup-' + Date.now();
24
+ await fs.move(destination, backup);
25
+ }
26
+
27
+ await fs.copy(source, destination, { overwrite: true });
28
+
29
+ return { destination };
30
+ }
@@ -0,0 +1,28 @@
1
+ // postinstall.js — runs after npm install
2
+ // Only installs skill + hooks when running as a global install (npx or npm install -g).
3
+ // Does NOT run during project-level npm install to avoid side effects.
4
+
5
+ 'use strict';
6
+
7
+ // Detect if this is a global install or npx execution
8
+ const isGlobal = process.env.npm_config_global === 'true';
9
+ const isNpx = process.env.npm_lifecycle_script?.includes('npx');
10
+
11
+ if (!isGlobal && !isNpx) {
12
+ // Project-level install — skip automatic setup
13
+ process.exit(0);
14
+ }
15
+
16
+ import('./install-skill.js')
17
+ .then(({ default: installSkill }) => {
18
+ const pkg = new URL('..', import.meta.url).pathname;
19
+ return installSkill(pkg.replace(/\/$/, ''));
20
+ })
21
+ .then(() => {
22
+ console.log('✓ ODD Studio: /odd skill installed into Claude Code');
23
+ })
24
+ .catch((e) => {
25
+ // Non-fatal — user can run odd-studio init manually
26
+ console.log('⚠ ODD Studio: Could not auto-install skill (' + e.message + ')');
27
+ console.log(' Run: npx odd-studio init to complete setup.');
28
+ });
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ const TEMPLATES = path.resolve(__dirname, '..', 'templates');
9
+
10
+ export default async function scaffoldProject(targetDir, projectName) {
11
+ await fs.ensureDir(targetDir);
12
+
13
+ // Copy templates
14
+ await fs.copy(TEMPLATES, targetDir, {
15
+ overwrite: false, // Never overwrite existing user files
16
+ filter: (src) => {
17
+ // Skip .gitkeep in non-empty directories
18
+ if (src.endsWith('.gitkeep')) {
19
+ const dir = path.dirname(src);
20
+ const destDir = dir.replace(TEMPLATES, targetDir);
21
+ return !fs.existsSync(destDir) || fs.readdirSync(destDir).length === 0;
22
+ }
23
+ return true;
24
+ },
25
+ });
26
+
27
+ // Patch CLAUDE.md with project name
28
+ const claudeMd = path.join(targetDir, 'CLAUDE.md');
29
+ if (fs.existsSync(claudeMd)) {
30
+ let content = await fs.readFile(claudeMd, 'utf8');
31
+ content = content.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
32
+ await fs.writeFile(claudeMd, content);
33
+ }
34
+
35
+ // Patch .odd/state.json with project name and timestamp
36
+ const stateFile = path.join(targetDir, '.odd', 'state.json');
37
+ if (fs.existsSync(stateFile)) {
38
+ const state = await fs.readJson(stateFile);
39
+ state.projectName = projectName;
40
+ state.initialisedAt = new Date().toISOString();
41
+ await fs.writeJson(stateFile, state, { spaces: 2 });
42
+ }
43
+
44
+ // Initialise git if not already a repo
45
+ const gitDir = path.join(targetDir, '.git');
46
+ if (!fs.existsSync(gitDir)) {
47
+ const { execSync } = await import('child_process');
48
+ try {
49
+ execSync('git init', { cwd: targetDir, stdio: 'ignore' });
50
+ execSync('git add -A', { cwd: targetDir, stdio: 'ignore' });
51
+ execSync(`git commit -m "Initial ODD Studio scaffold for ${projectName}"`, {
52
+ cwd: targetDir,
53
+ stdio: 'ignore',
54
+ });
55
+ } catch {
56
+ // Git init is best-effort — don't fail the whole install
57
+ }
58
+ }
59
+
60
+ return { targetDir };
61
+ }
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
7
+ const HOOKS_DEST = path.join(os.homedir(), '.claude', 'hooks');
8
+
9
+ // The hooks odd-studio installs, keyed by filename
10
+ const ODD_HOOKS = {
11
+ 'odd-git-safety.sh': {
12
+ event: 'PreToolUse',
13
+ matcher: 'Bash',
14
+ timeout: 5,
15
+ statusMessage: '🔒 ODD git safety check...',
16
+ },
17
+ 'odd-destructive-guard.sh': {
18
+ event: 'PreToolUse',
19
+ matcher: 'Bash',
20
+ timeout: 5,
21
+ statusMessage: '🛡️ ODD destructive command guard...',
22
+ },
23
+ 'odd-outcome-quality.sh': {
24
+ event: 'PostToolUse',
25
+ matcher: 'Write',
26
+ timeout: 5,
27
+ statusMessage: '🎯 ODD outcome quality check...',
28
+ },
29
+ 'odd-ui-check.sh': {
30
+ event: 'PostToolUse',
31
+ matcher: 'Edit',
32
+ timeout: 3,
33
+ statusMessage: '🎨 ODD UI quality reminder...',
34
+ },
35
+ 'odd-session-save.sh': {
36
+ event: 'PostToolUse',
37
+ matcher: 'Bash',
38
+ timeout: 10,
39
+ statusMessage: '💾 ODD session save...',
40
+ },
41
+ 'odd-pre-build.sh': {
42
+ event: 'PreToolUse',
43
+ matcher: 'Bash',
44
+ timeout: 5,
45
+ statusMessage: '🏗️ ODD build safety check...',
46
+ },
47
+ };
48
+
49
+ export default async function setupHooks(packageRoot, options = {}) {
50
+ const hooksSource = path.join(packageRoot, 'hooks');
51
+
52
+ // Copy hook scripts to ~/.claude/hooks/
53
+ await fs.ensureDir(HOOKS_DEST);
54
+ for (const hookFile of Object.keys(ODD_HOOKS)) {
55
+ const src = path.join(hooksSource, hookFile);
56
+ const dest = path.join(HOOKS_DEST, hookFile);
57
+ if (fs.existsSync(src)) {
58
+ await fs.copy(src, dest, { overwrite: true });
59
+ // Make executable
60
+ await fs.chmod(dest, 0o755);
61
+ }
62
+ }
63
+
64
+ // Read or initialise settings.json
65
+ await fs.ensureDir(path.dirname(SETTINGS_PATH));
66
+ let settings = {};
67
+ if (fs.existsSync(SETTINGS_PATH)) {
68
+ settings = await fs.readJson(SETTINGS_PATH);
69
+ }
70
+ if (!settings.hooks) settings.hooks = {};
71
+
72
+ // Build hook entries
73
+ const hooksByEvent = {};
74
+ for (const [hookFile, config] of Object.entries(ODD_HOOKS)) {
75
+ const { event, matcher, timeout, statusMessage } = config;
76
+ if (!hooksByEvent[event]) hooksByEvent[event] = {};
77
+ if (!hooksByEvent[event][matcher]) hooksByEvent[event][matcher] = [];
78
+ hooksByEvent[event][matcher].push({
79
+ type: 'command',
80
+ command: path.join(HOOKS_DEST, hookFile),
81
+ timeout,
82
+ statusMessage,
83
+ });
84
+ }
85
+
86
+ // Merge into existing hooks, tagged with odd-studio so we can upgrade cleanly
87
+ for (const [event, matchers] of Object.entries(hooksByEvent)) {
88
+ if (!settings.hooks[event]) settings.hooks[event] = [];
89
+ for (const [matcher, hookList] of Object.entries(matchers)) {
90
+ // Remove existing odd-studio hooks for this matcher (clean upgrade)
91
+ settings.hooks[event] = settings.hooks[event].filter(
92
+ (entry) => !(entry.matcher === matcher && entry._oddStudio)
93
+ );
94
+ settings.hooks[event].push({
95
+ matcher,
96
+ _oddStudio: true,
97
+ hooks: hookList,
98
+ });
99
+ }
100
+ }
101
+
102
+ await fs.writeJson(SETTINGS_PATH, settings, { spaces: 2 });
103
+
104
+ return { hookCount: Object.keys(ODD_HOOKS).length };
105
+ }