joycraft 0.6.14 → 0.6.15

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,148 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/version.ts
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
5
+ import { join, dirname } from "path";
6
+ import { createHash } from "crypto";
7
+
8
+ // src/harness.ts
9
+ import { createInterface } from "readline";
10
+ var HARNESSES = ["claude", "codex", "pi"];
11
+ var HARNESS_LABELS = {
12
+ claude: "Claude Code (.claude/)",
13
+ codex: "OpenAI Codex (.agents/)",
14
+ pi: "Pi (.pi/)"
15
+ };
16
+ function isHarness(value) {
17
+ return HARNESSES.includes(value);
18
+ }
19
+ function sanitizeHarnesses(value) {
20
+ if (!Array.isArray(value)) return null;
21
+ const present = new Set(
22
+ value.filter((v) => typeof v === "string").map((v) => v.trim().toLowerCase())
23
+ );
24
+ return HARNESSES.filter((h) => present.has(h));
25
+ }
26
+ function parseHarnessSelection(answer) {
27
+ const tokens = answer.split(/[,\s]+/).map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0);
28
+ if (tokens.length === 1 && tokens[0] === "all") {
29
+ return [...HARNESSES];
30
+ }
31
+ const selected = [];
32
+ for (const token of tokens) {
33
+ if (!isHarness(token)) return null;
34
+ if (!selected.includes(token)) selected.push(token);
35
+ }
36
+ return selected;
37
+ }
38
+ async function promptHarnesses() {
39
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
40
+ console.log("\nWhich AI harnesses should Joycraft install?");
41
+ for (const h of HARNESSES) {
42
+ console.log(` ${h.padEnd(7)} \u2014 ${HARNESS_LABELS[h]}`);
43
+ }
44
+ return new Promise((resolve) => {
45
+ const ask = () => {
46
+ rl.question('Harnesses [comma-separated, or "all"] (none): ', (answer) => {
47
+ const parsed = parseHarnessSelection(answer);
48
+ if (parsed !== null) {
49
+ rl.close();
50
+ resolve(parsed);
51
+ return;
52
+ }
53
+ console.log(
54
+ `Unrecognized harness in '${answer.trim()}' \u2014 choose from ${HARNESSES.join(", ")} (comma-separated), or leave empty for none.`
55
+ );
56
+ ask();
57
+ });
58
+ };
59
+ ask();
60
+ });
61
+ }
62
+ async function resolveHarnesses(interactive) {
63
+ if (interactive) {
64
+ return promptHarnesses();
65
+ }
66
+ return [...HARNESSES];
67
+ }
68
+
69
+ // src/version.ts
70
+ var STATE_PATH = join("docs", ".joycraft", "state.json");
71
+ var LEGACY_VERSION_FILE = ".joycraft-version";
72
+ var LEGACY_CLAUDE_STATE_PATH = join(".claude", ".joycraft", "state.json");
73
+ var HASH_LENGTH = 16;
74
+ var DEFAULT_GITIGNORE_PROFILE = "shared";
75
+ function parseGitignoreProfile(value) {
76
+ const v = typeof value === "string" ? value.trim().toLowerCase() : value;
77
+ return v === "shared" || v === "private" ? v : null;
78
+ }
79
+ function hashContent(content) {
80
+ return createHash("sha256").update(content).digest("hex");
81
+ }
82
+ function truncateHash(hash) {
83
+ return hash.slice(0, HASH_LENGTH);
84
+ }
85
+ function readVersion(dir) {
86
+ const filePath = join(dir, STATE_PATH);
87
+ if (!existsSync(filePath)) return null;
88
+ try {
89
+ const raw = readFileSync(filePath, "utf-8");
90
+ const parsed = JSON.parse(raw);
91
+ if (typeof parsed.version === "string" && typeof parsed.files === "object") {
92
+ const profile = parseGitignoreProfile(parsed.gitignoreProfile);
93
+ const harnesses = sanitizeHarnesses(parsed.harnesses);
94
+ return {
95
+ version: parsed.version,
96
+ files: parsed.files,
97
+ ...profile ? { gitignoreProfile: profile } : {},
98
+ ...harnesses ? { harnesses } : {}
99
+ };
100
+ }
101
+ return null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+ function writeVersion(dir, version, files, gitignoreProfile, harnesses) {
107
+ const filePath = join(dir, STATE_PATH);
108
+ const existing = readVersion(dir);
109
+ const profile = gitignoreProfile ?? existing?.gitignoreProfile;
110
+ const selectedHarnesses = harnesses ?? existing?.harnesses;
111
+ const truncated = {};
112
+ for (const [path, hash] of Object.entries(files)) {
113
+ truncated[path] = truncateHash(hash);
114
+ }
115
+ const data = {
116
+ version,
117
+ files: truncated,
118
+ ...profile ? { gitignoreProfile: profile } : {},
119
+ ...selectedHarnesses ? { harnesses: selectedHarnesses } : {}
120
+ };
121
+ mkdirSync(dirname(filePath), { recursive: true });
122
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
123
+ }
124
+ function getLevel(dir) {
125
+ const hasAutofix = existsSync(join(dir, ".github", "workflows", "autofix.yml"));
126
+ if (!hasAutofix) return 4;
127
+ const claudeMdPath = join(dir, "CLAUDE.md");
128
+ if (!existsSync(claudeMdPath)) return 4;
129
+ const content = readFileSync(claudeMdPath, "utf-8");
130
+ return content.includes("## External Validation") ? 5 : 4;
131
+ }
132
+
133
+ export {
134
+ HARNESSES,
135
+ sanitizeHarnesses,
136
+ resolveHarnesses,
137
+ STATE_PATH,
138
+ LEGACY_VERSION_FILE,
139
+ LEGACY_CLAUDE_STATE_PATH,
140
+ DEFAULT_GITIGNORE_PROFILE,
141
+ parseGitignoreProfile,
142
+ hashContent,
143
+ truncateHash,
144
+ readVersion,
145
+ writeVersion,
146
+ getLevel
147
+ };
148
+ //# sourceMappingURL=chunk-34IWIKXS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/version.ts","../src/harness.ts"],"sourcesContent":["import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport { createHash } from 'node:crypto';\nimport { sanitizeHarnesses, type Harness } from './harness.js';\n\n/**\n * Project-relative path to Joycraft's upgrade-state file.\n *\n * Hidden inside `docs/` — the one directory `init` always creates regardless of\n * which harnesses are selected — so a single-harness install carries no\n * foreign-harness footprint (a Codex-only project has no `.claude/`, etc.). The\n * state is Joycraft's own bookkeeping (version, file hashes, gitignore profile),\n * not a harness artifact, so it lives in a harness-neutral home. Never at the\n * repo root, never committed (init/upgrade gitignore it).\n *\n * Two legacy locations are migrated on upgrade: the original repo-root\n * `.joycraft-version` (see LEGACY_VERSION_FILE) and the later\n * `.claude/.joycraft/state.json` (see LEGACY_CLAUDE_STATE_PATH).\n */\nexport const STATE_PATH = join('docs', '.joycraft', 'state.json');\n\n/** The original repo-root state path. Kept only so `upgrade` can migrate it. */\nexport const LEGACY_VERSION_FILE = '.joycraft-version';\n\n/**\n * The interim `.claude/`-nested state path, used before state moved to a\n * harness-neutral `docs/` home. Kept only so `upgrade` can migrate it (and so a\n * Codex/Pi-only re-init stops leaving a stray `.claude/` behind).\n */\nexport const LEGACY_CLAUDE_STATE_PATH = join('.claude', '.joycraft', 'state.json');\n\n/**\n * Length we truncate stored hashes to. Full SHA-256 is 64 hex chars; 16 hex\n * (64 bits) is ample to detect customization across ~100 managed files and\n * shrinks the state ~4×. Compare truncated-on-both-sides (see upgrade.ts).\n */\nconst HASH_LENGTH = 16;\n\n/**\n * How much of the Joycraft harness is tracked in git.\n * - `shared` — commit skills/agents/pi so teammates get the workflow (default).\n * - `private` — gitignore .claude/, .agents/, .pi/; track only CLAUDE.md,\n * AGENTS.md, and docs/.\n */\nexport type GitignoreProfile = 'shared' | 'private';\n\nexport const DEFAULT_GITIGNORE_PROFILE: GitignoreProfile = 'shared';\n\n/**\n * Narrow an arbitrary value to a GitignoreProfile, or null if unrecognized.\n * Strings are normalized (trim + lowercase) here so every call site — flag,\n * prompt, persisted state — gets the same case-insensitivity for free.\n */\nexport function parseGitignoreProfile(value: unknown): GitignoreProfile | null {\n const v = typeof value === 'string' ? value.trim().toLowerCase() : value;\n return v === 'shared' || v === 'private' ? v : null;\n}\n\nexport interface VersionInfo {\n version: string;\n files: Record<string, string>;\n /**\n * The gitignore profile chosen at init/upgrade. Absent on state written by\n * Joycraft versions before this field existed — treat absent as `shared`.\n */\n gitignoreProfile?: GitignoreProfile;\n /**\n * The harnesses installed at init (claude/codex/pi). `upgrade` reads this so\n * it only refreshes the harnesses the project actually uses. Absent on state\n * written before harness selection existed — callers treat absent as \"all\n * three\" for backward compatibility.\n */\n harnesses?: Harness[];\n}\n\nexport function hashContent(content: string): string {\n return createHash('sha256').update(content).digest('hex');\n}\n\n/** Truncate a (full) content hash to the stored length. Idempotent for already-short input. */\nexport function truncateHash(hash: string): string {\n return hash.slice(0, HASH_LENGTH);\n}\n\nexport function readVersion(dir: string): VersionInfo | null {\n const filePath = join(dir, STATE_PATH);\n if (!existsSync(filePath)) return null;\n try {\n const raw = readFileSync(filePath, 'utf-8');\n const parsed = JSON.parse(raw);\n if (typeof parsed.version === 'string' && typeof parsed.files === 'object') {\n // Sanitize the profile: ignore unknown/legacy values rather than\n // returning them (absent or bogus → undefined, callers default to shared).\n const profile = parseGitignoreProfile(parsed.gitignoreProfile);\n // Sanitize harnesses similarly: drop unknown tokens; null (not an array)\n // means \"no recorded selection\" → callers default to all three.\n const harnesses = sanitizeHarnesses(parsed.harnesses);\n return {\n version: parsed.version,\n files: parsed.files,\n ...(profile ? { gitignoreProfile: profile } : {}),\n ...(harnesses ? { harnesses } : {}),\n };\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport function writeVersion(\n dir: string,\n version: string,\n files: Record<string, string>,\n gitignoreProfile?: GitignoreProfile,\n harnesses?: Harness[]\n): void {\n const filePath = join(dir, STATE_PATH);\n // An omitted profile/harness-list means \"no new decision\", not \"clear it\":\n // preserve whatever is already persisted so call sites that only refresh\n // version/hashes can never silently strip a saved choice.\n const existing = readVersion(dir);\n const profile = gitignoreProfile ?? existing?.gitignoreProfile;\n const selectedHarnesses = harnesses ?? existing?.harnesses;\n // Store truncated hashes — single source of truth for the on-disk shape.\n const truncated: Record<string, string> = {};\n for (const [path, hash] of Object.entries(files)) {\n truncated[path] = truncateHash(hash);\n }\n const data: VersionInfo = {\n version,\n files: truncated,\n ...(profile ? { gitignoreProfile: profile } : {}),\n ...(selectedHarnesses ? { harnesses: selectedHarnesses } : {}),\n };\n mkdirSync(dirname(filePath), { recursive: true });\n writeFileSync(filePath, JSON.stringify(data, null, 2) + '\\n', 'utf-8');\n}\n\n/**\n * Detect the current Joycraft harness level for a project directory.\n * Returns 5 if Level 5 artifacts (autofix workflow + External Validation) are present, 4 otherwise.\n */\nexport function getLevel(dir: string): number {\n const hasAutofix = existsSync(join(dir, '.github', 'workflows', 'autofix.yml'));\n if (!hasAutofix) return 4;\n const claudeMdPath = join(dir, 'CLAUDE.md');\n if (!existsSync(claudeMdPath)) return 4;\n const content = readFileSync(claudeMdPath, 'utf-8');\n return content.includes('## External Validation') ? 5 : 4;\n}\n","import { createInterface } from 'node:readline';\n\n/**\n * The AI coding harnesses Joycraft can install into a project. Each maps to a\n * hidden config dir and a skills install path:\n * - claude → .claude/ (Claude Code)\n * - codex → .agents/ (OpenAI Codex)\n * - pi → .pi/ (Pi)\n *\n * Single source of truth: the menu, the parser, and the install gates in\n * init.ts all derive from this list so a new harness can't leave a path stale.\n */\nexport const HARNESSES = ['claude', 'codex', 'pi'] as const;\nexport type Harness = (typeof HARNESSES)[number];\n\n/** Human-readable one-liner per harness, shown in the interactive menu. */\nconst HARNESS_LABELS: Record<Harness, string> = {\n claude: 'Claude Code (.claude/)',\n codex: 'OpenAI Codex (.agents/)',\n pi: 'Pi (.pi/)',\n};\n\nfunction isHarness(value: string): value is Harness {\n return (HARNESSES as readonly string[]).includes(value);\n}\n\n/**\n * Sanitize an arbitrary value (e.g. parsed from state.json) into a harness list,\n * dropping anything unrecognized rather than rejecting the whole list — old or\n * hand-edited state must degrade gracefully. Returns null when the input isn't\n * an array at all (caller treats null as \"no recorded selection\"). Order follows\n * the canonical HARNESSES order and is deduped.\n */\nexport function sanitizeHarnesses(value: unknown): Harness[] | null {\n if (!Array.isArray(value)) return null;\n const present = new Set(\n value.filter((v): v is string => typeof v === 'string').map((v) => v.trim().toLowerCase())\n );\n return HARNESSES.filter((h) => present.has(h));\n}\n\n/**\n * Parse a comma/space-separated answer into a deduped, validated harness list.\n * Returns null when any token is unrecognized so the caller can re-ask rather\n * than silently dropping a typo. An empty answer yields an empty array (the\n * \"install nothing\" case the caller handles explicitly).\n */\nexport function parseHarnessSelection(answer: string): Harness[] | null {\n const tokens = answer\n .split(/[,\\s]+/)\n .map((t) => t.trim().toLowerCase())\n .filter((t) => t.length > 0);\n\n // \"all\" is a convenience alias for every harness.\n if (tokens.length === 1 && tokens[0] === 'all') {\n return [...HARNESSES];\n }\n\n const selected: Harness[] = [];\n for (const token of tokens) {\n if (!isHarness(token)) return null;\n if (!selected.includes(token)) selected.push(token);\n }\n return selected;\n}\n\n/**\n * Interactive multi-select for which harnesses to install. An empty answer is a\n * deliberate \"none\" — the caller prints the run-again message and bails. An\n * unrecognized token re-asks instead of being coerced.\n */\nasync function promptHarnesses(): Promise<Harness[]> {\n const rl = createInterface({ input: process.stdin, output: process.stdout });\n console.log('\\nWhich AI harnesses should Joycraft install?');\n for (const h of HARNESSES) {\n console.log(` ${h.padEnd(7)} — ${HARNESS_LABELS[h]}`);\n }\n return new Promise((resolve) => {\n const ask = (): void => {\n rl.question('Harnesses [comma-separated, or \"all\"] (none): ', (answer) => {\n const parsed = parseHarnessSelection(answer);\n if (parsed !== null) {\n rl.close();\n resolve(parsed);\n return;\n }\n console.log(\n `Unrecognized harness in '${answer.trim()}' — choose from ${HARNESSES.join(', ')} (comma-separated), or leave empty for none.`\n );\n ask();\n });\n };\n ask();\n });\n}\n\n/**\n * Resolve which harnesses to install:\n * - interactive (TTY): show the multi-select menu; honor an empty \"none\"\n * - non-interactive: install all three, preserving long-standing init behavior\n * for scripted/CI runs that can't answer a prompt.\n */\nexport async function resolveHarnesses(interactive: boolean): Promise<Harness[]> {\n if (interactive) {\n return promptHarnesses();\n }\n return [...HARNESSES];\n}\n"],"mappings":"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","names":[]}