geeknative 0.0.0-stage → 0.1.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.
Files changed (159) hide show
  1. package/LICENSE +202 -0
  2. package/bin/geeknative.js +12 -0
  3. package/package.json +31 -4
  4. package/src/cli.js +61 -0
  5. package/src/codegen/app.js +97 -0
  6. package/src/codegen/icons.js +87 -0
  7. package/src/codegen/index.js +44 -0
  8. package/src/codegen/strings.js +308 -0
  9. package/src/codegen/tokens.js +449 -0
  10. package/src/codegen/util.js +122 -0
  11. package/src/commands/check.js +143 -0
  12. package/src/commands/create.js +78 -0
  13. package/src/commands/doctor.js +108 -0
  14. package/src/commands/gen.js +44 -0
  15. package/src/commands/parity.js +134 -0
  16. package/src/commands/run.js +91 -0
  17. package/src/lib/exec.js +77 -0
  18. package/src/lib/fsutil.js +92 -0
  19. package/src/lib/log.js +37 -0
  20. package/src/lib/managed.js +80 -0
  21. package/src/lib/project.js +52 -0
  22. package/src/lib/toolchains.js +126 -0
  23. package/template/.claude/agents/android-engineer.md +15 -0
  24. package/template/.claude/agents/ios-engineer.md +15 -0
  25. package/template/.claude/agents/web-engineer.md +15 -0
  26. package/template/.claude/settings.json +65 -0
  27. package/template/.geeknative/framework.mk +41 -0
  28. package/template/.geeknative/hooks/after-edit.mjs +28 -0
  29. package/template/.geeknative/hooks/parity-guard.mjs +49 -0
  30. package/template/.geeknative/hooks/subagent-context.mjs +24 -0
  31. package/template/.geeknative/playbooks/bug.md +7 -0
  32. package/template/.geeknative/playbooks/design.md +10 -0
  33. package/template/.geeknative/playbooks/engineering.md +9 -0
  34. package/template/.geeknative/playbooks/feature.md +11 -0
  35. package/template/.geeknative/playbooks/platform-specific.md +8 -0
  36. package/template/.geeknative/playbooks/product.md +12 -0
  37. package/template/.geeknative/templates/feature.md +49 -0
  38. package/template/.gemini/settings.json +5 -0
  39. package/template/.github/dependabot.yml +18 -0
  40. package/template/AGENTS.md +97 -0
  41. package/template/CLAUDE.md +9 -0
  42. package/template/DESIGN.md +65 -0
  43. package/template/Makefile +6 -0
  44. package/template/PRODUCT.md +40 -0
  45. package/template/README.md +28 -0
  46. package/template/_gitignore +33 -0
  47. package/template/apps/android/AGENTS.md +66 -0
  48. package/template/apps/android/CLAUDE.md +1 -0
  49. package/template/apps/android/app/build.gradle.kts +68 -0
  50. package/template/apps/android/app/identity.properties +4 -0
  51. package/template/apps/android/app/src/main/AndroidManifest.xml +24 -0
  52. package/template/apps/android/app/src/main/kotlin/app/App.kt +15 -0
  53. package/template/apps/android/app/src/main/kotlin/app/MainActivity.kt +49 -0
  54. package/template/apps/android/app/src/main/kotlin/app/core/AppContainer.kt +27 -0
  55. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppCard.kt +30 -0
  56. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppTheme.kt +35 -0
  57. package/template/apps/android/app/src/main/kotlin/app/core/navigation/AppNavigation.kt +40 -0
  58. package/template/apps/android/app/src/main/kotlin/app/core/navigation/Route.kt +13 -0
  59. package/template/apps/android/app/src/main/kotlin/app/features/home/HomeScreen.kt +67 -0
  60. package/template/apps/android/app/src/main/kotlin/app/features/settings/Appearance.kt +22 -0
  61. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsRepository.kt +40 -0
  62. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsScreen.kt +113 -0
  63. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsViewModel.kt +28 -0
  64. package/template/apps/android/app/src/main/kotlin/app/generated/AppIdentity.kt +10 -0
  65. package/template/apps/android/app/src/main/kotlin/app/generated/Icons.kt +15 -0
  66. package/template/apps/android/app/src/main/kotlin/app/generated/Tokens.kt +144 -0
  67. package/template/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml +17 -0
  68. package/template/apps/android/app/src/main/res/mipmap/ic_launcher.xml +10 -0
  69. package/template/apps/android/app/src/main/res/mipmap/ic_launcher_round.xml +10 -0
  70. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
  71. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  72. package/template/apps/android/app/src/main/res/values/generated_colors.xml +15 -0
  73. package/template/apps/android/app/src/main/res/values/generated_strings.xml +16 -0
  74. package/template/apps/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
  75. package/template/apps/android/app/src/main/res/values/themes.xml +7 -0
  76. package/template/apps/android/app/src/main/res/values-night/generated_colors.xml +15 -0
  77. package/template/apps/android/app/src/main/res/values-night/themes.xml +6 -0
  78. package/template/apps/android/app/src/test/kotlin/app/features/settings/SettingsTest.kt +90 -0
  79. package/template/apps/android/build.gradle.kts +5 -0
  80. package/template/apps/android/gradle/gradle-daemon-jvm.properties +12 -0
  81. package/template/apps/android/gradle/libs.versions.toml +37 -0
  82. package/template/apps/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  83. package/template/apps/android/gradle/wrapper/gradle-wrapper.properties +9 -0
  84. package/template/apps/android/gradle.properties +7 -0
  85. package/template/apps/android/gradlew +248 -0
  86. package/template/apps/android/gradlew.bat +82 -0
  87. package/template/apps/android/settings.gradle.kts +29 -0
  88. package/template/apps/ios/AGENTS.md +70 -0
  89. package/template/apps/ios/App/AppMain.swift +13 -0
  90. package/template/apps/ios/App/Core/AppContainer.swift +22 -0
  91. package/template/apps/ios/App/Core/DesignSystem/AppCard.swift +15 -0
  92. package/template/apps/ios/App/Core/Navigation/Route.swift +4 -0
  93. package/template/apps/ios/App/Core/RootView.swift +32 -0
  94. package/template/apps/ios/App/Features/Home/HomeScreen.swift +35 -0
  95. package/template/apps/ios/App/Features/Settings/Appearance.swift +27 -0
  96. package/template/apps/ios/App/Features/Settings/SettingsRepository.swift +40 -0
  97. package/template/apps/ios/App/Features/Settings/SettingsScreen.swift +40 -0
  98. package/template/apps/ios/App/Features/Settings/SettingsViewModel.swift +23 -0
  99. package/template/apps/ios/App/Generated/AppIdentity.swift +9 -0
  100. package/template/apps/ios/App/Generated/Colors.xcassets/AccentColor.colorset/Contents.json +38 -0
  101. package/template/apps/ios/App/Generated/Colors.xcassets/Contents.json +6 -0
  102. package/template/apps/ios/App/Generated/Icons.swift +8 -0
  103. package/template/apps/ios/App/Generated/Localizable.xcstrings +127 -0
  104. package/template/apps/ios/App/Generated/Strings.swift +40 -0
  105. package/template/apps/ios/App/Generated/Tokens.swift +65 -0
  106. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  107. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  108. package/template/apps/ios/App/Resources/Assets.xcassets/Contents.json +6 -0
  109. package/template/apps/ios/App.xcodeproj/project.pbxproj +298 -0
  110. package/template/apps/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  111. package/template/apps/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +89 -0
  112. package/template/apps/ios/AppTests/SettingsTests.swift +54 -0
  113. package/template/apps/ios/CLAUDE.md +1 -0
  114. package/template/apps/ios/Config/App.xcconfig +23 -0
  115. package/template/apps/ios/Config/AppTests.xcconfig +10 -0
  116. package/template/apps/ios/Config/Base.xcconfig +25 -0
  117. package/template/apps/ios/Config/Debug.xcconfig +9 -0
  118. package/template/apps/ios/Config/Identity.xcconfig +5 -0
  119. package/template/apps/ios/Config/Release.xcconfig +6 -0
  120. package/template/apps/web/AGENTS.md +63 -0
  121. package/template/apps/web/CLAUDE.md +1 -0
  122. package/template/apps/web/package.json +38 -0
  123. package/template/apps/web/public/favicon.svg +6 -0
  124. package/template/apps/web/src/core/AppContainer.ts +18 -0
  125. package/template/apps/web/src/core/AppearanceEffect.tsx +31 -0
  126. package/template/apps/web/src/core/designsystem/AppCard.tsx +6 -0
  127. package/template/apps/web/src/features/home/HomeScreen.tsx +15 -0
  128. package/template/apps/web/src/features/home/home.test.tsx +22 -0
  129. package/template/apps/web/src/features/settings/Appearance.ts +29 -0
  130. package/template/apps/web/src/features/settings/LocalStorageSettingsRepository.ts +21 -0
  131. package/template/apps/web/src/features/settings/SettingsRepository.ts +7 -0
  132. package/template/apps/web/src/features/settings/SettingsScreen.tsx +44 -0
  133. package/template/apps/web/src/features/settings/settings.test.ts +45 -0
  134. package/template/apps/web/src/features/settings/useSettingsViewModel.ts +42 -0
  135. package/template/apps/web/src/generated/app.ts +9 -0
  136. package/template/apps/web/src/generated/icons.ts +9 -0
  137. package/template/apps/web/src/generated/strings.ts +88 -0
  138. package/template/apps/web/src/generated/tokens.css +86 -0
  139. package/template/apps/web/src/routeTree.gen.ts +86 -0
  140. package/template/apps/web/src/router.tsx +23 -0
  141. package/template/apps/web/src/routes/__root.tsx +72 -0
  142. package/template/apps/web/src/routes/index.tsx +6 -0
  143. package/template/apps/web/src/routes/settings.tsx +9 -0
  144. package/template/apps/web/src/styles.css +9 -0
  145. package/template/apps/web/tsconfig.json +26 -0
  146. package/template/apps/web/tsr.config.json +3 -0
  147. package/template/apps/web/vite.config.ts +9 -0
  148. package/template/apps/web/vitest.config.ts +9 -0
  149. package/template/contracts/app.json +7 -0
  150. package/template/contracts/icons.json +5 -0
  151. package/template/contracts/strings/en.json +13 -0
  152. package/template/contracts/tokens.json +40 -0
  153. package/template/package.json +17 -0
  154. package/template/spec/decisions.md +10 -0
  155. package/template/spec/features/home.md +39 -0
  156. package/template/spec/features/settings.md +50 -0
  157. package/template/spec/navigation.md +16 -0
  158. package/template/spec/parity.md +29 -0
  159. package/README.md +0 -3
@@ -0,0 +1,77 @@
1
+ // @ts-check
2
+ import { spawn, spawnSync } from 'node:child_process'
3
+ import { createWriteStream, mkdirSync } from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ /**
7
+ * Runs a command, writing all of its output to `logFile`.
8
+ * Agents get a short summary on screen and can open the log when they need detail.
9
+ * @param {string} cmd
10
+ * @param {string[]} args
11
+ * @param {{ cwd?: string, logFile: string, env?: Record<string, string> }} options
12
+ * @returns {Promise<{ code: number, lines: string[] }>}
13
+ */
14
+ export function runLogged(cmd, args, { cwd, logFile, env }) {
15
+ mkdirSync(path.dirname(logFile), { recursive: true })
16
+ return new Promise((resolve) => {
17
+ const out = createWriteStream(logFile)
18
+ out.write(`$ ${[cmd, ...args].join(' ')}\n\n`)
19
+ const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env } })
20
+ /** @type {string[]} */
21
+ const lines = []
22
+ let pending = ''
23
+ /** @param {Buffer} chunk */
24
+ const collect = (chunk) => {
25
+ // Plain text: easier for agents to read, and for us to match.
26
+ // eslint-disable-next-line no-control-regex
27
+ const text = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
28
+ out.write(text)
29
+ pending += text
30
+ const parts = pending.split('\n')
31
+ pending = parts.pop() ?? ''
32
+ lines.push(...parts)
33
+ }
34
+ child.stdout.on('data', collect)
35
+ child.stderr.on('data', collect)
36
+ child.on('error', (error) => {
37
+ out.end()
38
+ resolve({ code: 127, lines: [String(error)] })
39
+ })
40
+ child.on('close', (code) => {
41
+ if (pending) lines.push(pending)
42
+ out.end()
43
+ resolve({ code: code ?? 1, lines })
44
+ })
45
+ })
46
+ }
47
+
48
+ /**
49
+ * Runs a command attached to this terminal (for dev servers and interactive tools).
50
+ * @param {string} cmd
51
+ * @param {string[]} args
52
+ * @param {{ cwd?: string, env?: Record<string, string> }} [options]
53
+ * @returns {Promise<number>}
54
+ */
55
+ export function runInherit(cmd, args, { cwd, env } = {}) {
56
+ return new Promise((resolve) => {
57
+ const child = spawn(cmd, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env } })
58
+ child.on('error', () => resolve(127))
59
+ child.on('close', (code) => resolve(code ?? 1))
60
+ })
61
+ }
62
+
63
+ /**
64
+ * Runs a command and returns its trimmed stdout, or null when it fails or isn't installed.
65
+ * @param {string} cmd
66
+ * @param {string[]} args
67
+ * @param {{ cwd?: string, input?: string, timeout?: number }} [options]
68
+ */
69
+ export function capture(cmd, args, options = {}) {
70
+ const result = spawnSync(cmd, args, { encoding: 'utf8', ...options })
71
+ return result.status === 0 ? result.stdout.trim() : null
72
+ }
73
+
74
+ /** @param {string} cmd */
75
+ export function which(cmd) {
76
+ return capture('which', [cmd])
77
+ }
@@ -0,0 +1,92 @@
1
+ // @ts-check
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { CliError } from './log.js'
5
+
6
+ /** Build output and local state that never belongs in a template copy. */
7
+ const ARTIFACTS = new Set([
8
+ 'node_modules',
9
+ '.build',
10
+ 'build',
11
+ '.gradle',
12
+ '.kotlin',
13
+ '.idea',
14
+ 'DerivedData',
15
+ 'xcuserdata',
16
+ '.output',
17
+ '.tanstack',
18
+ '.nitro',
19
+ '.vinxi',
20
+ 'dist',
21
+ '.DS_Store',
22
+ 'local.properties',
23
+ 'package-lock.json',
24
+ ])
25
+
26
+ /**
27
+ * Reads and parses a JSON file, with an error message an agent can act on.
28
+ * @param {string} file
29
+ * @param {string} label shown in errors, e.g. "contracts/app.json"
30
+ */
31
+ export function readJson(file, label) {
32
+ let text
33
+ try {
34
+ text = readFileSync(file, 'utf8')
35
+ } catch {
36
+ throw new CliError(`${label} is missing.`)
37
+ }
38
+ try {
39
+ return JSON.parse(text)
40
+ } catch (error) {
41
+ throw new CliError(`${label} is not valid JSON: ${/** @type {Error} */ (error).message}`)
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Writes `content` only if it differs from what's on disk. Returns true when the file changed.
47
+ * @param {string} file
48
+ * @param {string | Buffer} content
49
+ */
50
+ export function writeIfChanged(file, content) {
51
+ const next = typeof content === 'string' ? Buffer.from(content) : content
52
+ if (existsSync(file) && readFileSync(file).equals(next)) return false
53
+ mkdirSync(path.dirname(file), { recursive: true })
54
+ writeFileSync(file, next)
55
+ return true
56
+ }
57
+
58
+ /**
59
+ * Lists files under `dir` recursively, skipping build artifacts and dependencies.
60
+ * @param {string} dir
61
+ * @returns {string[]} absolute paths
62
+ */
63
+ export function walk(dir) {
64
+ if (!existsSync(dir)) return []
65
+ /** @type {string[]} */
66
+ const files = []
67
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
68
+ if (ARTIFACTS.has(entry.name)) continue
69
+ const full = path.join(dir, entry.name)
70
+ if (entry.isDirectory()) files.push(...walk(full))
71
+ else files.push(full)
72
+ }
73
+ return files
74
+ }
75
+
76
+ /**
77
+ * Copies the template into a new project. Files named `_gitignore` become `.gitignore`
78
+ * (npm drops real .gitignore files from published packages).
79
+ * @param {string} from
80
+ * @param {string} to
81
+ * @param {{ keepGitignoreNames?: boolean }} [options]
82
+ */
83
+ export function copyTemplate(from, to, { keepGitignoreNames = false } = {}) {
84
+ for (const file of walk(from)) {
85
+ const relative = path.relative(from, file)
86
+ if (relative.startsWith(`.geeknative${path.sep}logs${path.sep}`)) continue
87
+ const renamed = keepGitignoreNames ? relative : relative.replace(/(^|[\\/])_gitignore$/, '$1.gitignore')
88
+ const target = path.join(to, renamed)
89
+ mkdirSync(path.dirname(target), { recursive: true })
90
+ copyFileSync(file, target)
91
+ }
92
+ }
package/src/lib/log.js ADDED
@@ -0,0 +1,37 @@
1
+ // @ts-check
2
+
3
+ const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
4
+
5
+ /** @param {number} code */
6
+ const paint = (code) => /** @param {unknown} text */ (text) =>
7
+ useColor ? `\x1b[${code}m${text}\x1b[0m` : String(text)
8
+
9
+ export const color = {
10
+ bold: paint(1),
11
+ dim: paint(2),
12
+ red: paint(31),
13
+ green: paint(32),
14
+ yellow: paint(33),
15
+ cyan: paint(36),
16
+ }
17
+
18
+ /** @param {string} mark @param {string} label @param {string} message */
19
+ const line = (mark, label, message) => console.log(`${mark} ${label.padEnd(10)} ${message}`)
20
+
21
+ export const log = {
22
+ /** @param {string} message */
23
+ info: (message) => console.log(message),
24
+ /** @param {string} label @param {string} message */
25
+ step: (label, message) => line(color.cyan('▸'), label, message),
26
+ /** @param {string} label @param {string} message */
27
+ ok: (label, message) => line(color.green('✔'), label, message),
28
+ /** @param {string} label @param {string} message */
29
+ warn: (label, message) => line(color.yellow('!'), label, message),
30
+ /** @param {string} label @param {string} message */
31
+ fail: (label, message) => line(color.red('✖'), label, message),
32
+ /** @param {string} message */
33
+ detail: (message) => console.log(` ${message}`),
34
+ }
35
+
36
+ /** An expected failure: printed without a stack trace. */
37
+ export class CliError extends Error {}
@@ -0,0 +1,80 @@
1
+ // @ts-check
2
+ import { createHash } from 'node:crypto'
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
4
+ import path from 'node:path'
5
+ import { walk } from './fsutil.js'
6
+ import { rel } from './project.js'
7
+
8
+ /*
9
+ * Every file in a geeknative project has one owner:
10
+ * - Framework: the files and AGENTS.md blocks listed here. `geeknative upgrade` replaces them.
11
+ * - Project: everything else (docs, spec, contracts, app code). Upgrades never touch it.
12
+ * - Generated: output of `geeknative gen`, rebuilt from contracts.
13
+ * The lockfile records a hash of each framework-owned piece as installed, so an upgrade can
14
+ * tell untouched files (safe to replace) from locally edited ones (merge instead).
15
+ */
16
+
17
+ export const MANAGED_DIRS = [
18
+ '.geeknative/playbooks',
19
+ '.geeknative/templates',
20
+ '.geeknative/hooks',
21
+ ]
22
+
23
+ export const MANAGED_FILES = [
24
+ '.geeknative/framework.mk',
25
+ '.claude/agents/ios-engineer.md',
26
+ '.claude/agents/android-engineer.md',
27
+ '.claude/agents/web-engineer.md',
28
+ ]
29
+
30
+ /** Files with a framework-owned block between the markers below; the rest of the file is the project's. */
31
+ export const MANAGED_BLOCK_FILES = ['AGENTS.md', 'CLAUDE.md', 'apps/ios/AGENTS.md', 'apps/android/AGENTS.md', 'apps/web/AGENTS.md']
32
+
33
+ const BLOCK = /<!-- geeknative:begin[^>]*-->[\s\S]*?<!-- geeknative:end -->/
34
+
35
+ /** @param {string} text */
36
+ export function extractBlock(text) {
37
+ return text.match(BLOCK)?.[0] ?? null
38
+ }
39
+
40
+ /** @param {string | Buffer} content */
41
+ const hash = (content) => `sha256-${createHash('sha256').update(content).digest('hex')}`
42
+
43
+ /**
44
+ * Hashes every framework-owned file and block in a project.
45
+ * @param {string} root
46
+ */
47
+ export function managedHashes(root) {
48
+ /** @type {Record<string, string>} */
49
+ const files = {}
50
+ for (const dir of MANAGED_DIRS) {
51
+ for (const file of walk(path.join(root, dir))) files[rel(root, file)] = hash(readFileSync(file))
52
+ }
53
+ for (const file of MANAGED_FILES) {
54
+ const full = path.join(root, file)
55
+ if (existsSync(full)) files[file] = hash(readFileSync(full))
56
+ }
57
+ /** @type {Record<string, string>} */
58
+ const blocks = {}
59
+ for (const file of MANAGED_BLOCK_FILES) {
60
+ const full = path.join(root, file)
61
+ const block = existsSync(full) ? extractBlock(readFileSync(full, 'utf8')) : null
62
+ if (block) blocks[file] = hash(block)
63
+ }
64
+ return { files: sortKeys(files), blocks: sortKeys(blocks) }
65
+ }
66
+
67
+ /**
68
+ * Writes .geeknative/lock.json: the framework version and the hashes of what it installed.
69
+ * @param {string} root
70
+ * @param {string} version
71
+ */
72
+ export function writeLock(root, version) {
73
+ const lock = { geeknative: version, ...managedHashes(root) }
74
+ writeFileSync(path.join(root, '.geeknative', 'lock.json'), `${JSON.stringify(lock, null, 2)}\n`)
75
+ }
76
+
77
+ /** @param {Record<string, string>} object */
78
+ function sortKeys(object) {
79
+ return Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b)))
80
+ }
@@ -0,0 +1,52 @@
1
+ // @ts-check
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { CliError } from './log.js'
6
+
7
+ /** Root of the geeknative npm package. */
8
+ export const PACKAGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
9
+
10
+ /** The repo's template/ folder, which exists only when running from a git checkout. */
11
+ const REPO_TEMPLATE_DIR = path.resolve(PACKAGE_DIR, '../../template')
12
+
13
+ /** True when this CLI runs from a checkout of the geeknative repo rather than from npm. */
14
+ export function isLocalCheckout() {
15
+ return !existsSync(path.join(PACKAGE_DIR, 'template')) && existsSync(REPO_TEMPLATE_DIR)
16
+ }
17
+
18
+ /** The project template: bundled in the npm package, or the repo's template/ in a checkout. */
19
+ export function templateDir() {
20
+ const bundled = path.join(PACKAGE_DIR, 'template')
21
+ if (existsSync(bundled)) return bundled
22
+ if (existsSync(REPO_TEMPLATE_DIR)) return REPO_TEMPLATE_DIR
23
+ throw new CliError('Could not find the geeknative template.')
24
+ }
25
+
26
+ export function packageVersion() {
27
+ return JSON.parse(readFileSync(path.join(PACKAGE_DIR, 'package.json'), 'utf8')).version
28
+ }
29
+
30
+ /** Walks up from `start` to the project root: the folder that contains contracts/app.json. */
31
+ export function findProjectRoot(start = process.cwd()) {
32
+ let dir = path.resolve(start)
33
+ for (;;) {
34
+ if (existsSync(path.join(dir, 'contracts', 'app.json'))) return dir
35
+ const parent = path.dirname(dir)
36
+ if (parent === dir) {
37
+ throw new CliError(
38
+ 'Not inside a geeknative project (no contracts/app.json found). Run this from your project folder.',
39
+ )
40
+ }
41
+ dir = parent
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Path relative to the project root, with forward slashes.
47
+ * @param {string} root
48
+ * @param {string} file
49
+ */
50
+ export function rel(root, file) {
51
+ return path.relative(root, file).split(path.sep).join('/')
52
+ }
@@ -0,0 +1,126 @@
1
+ // @ts-check
2
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { capture } from './exec.js'
6
+
7
+ /** Android API level the app compiles against (apps/android/app/build.gradle.kts). */
8
+ export const ANDROID_COMPILE_SDK = 37
9
+
10
+ // ---------- Android ----------
11
+
12
+ /** @param {string} root */
13
+ function localPropertiesPath(root) {
14
+ return path.join(root, 'apps', 'android', 'local.properties')
15
+ }
16
+
17
+ /**
18
+ * Finds an Android SDK: local.properties, then ANDROID_HOME / ANDROID_SDK_ROOT, then common install locations.
19
+ * @param {string} root
20
+ * @returns {string | null}
21
+ */
22
+ export function findAndroidSdk(root) {
23
+ const local = localPropertiesPath(root)
24
+ if (existsSync(local)) {
25
+ const match = readFileSync(local, 'utf8').match(/^sdk\.dir=(.+)$/m)
26
+ if (match && existsSync(match[1].trim())) return match[1].trim()
27
+ }
28
+ const home = os.homedir()
29
+ const candidates = [
30
+ process.env.ANDROID_HOME,
31
+ process.env.ANDROID_SDK_ROOT,
32
+ path.join(home, 'Library', 'Android', 'sdk'),
33
+ '/opt/homebrew/share/android-commandlinetools',
34
+ '/usr/local/share/android-commandlinetools',
35
+ path.join(home, 'Android', 'Sdk'),
36
+ ]
37
+ for (const candidate of candidates) {
38
+ if (candidate && existsSync(path.join(candidate, 'platform-tools'))) return candidate
39
+ if (candidate && existsSync(path.join(candidate, 'platforms'))) return candidate
40
+ }
41
+ return null
42
+ }
43
+
44
+ /**
45
+ * Points Gradle at the SDK. local.properties is machine-specific and git-ignored.
46
+ * @param {string} root
47
+ * @param {string} sdk
48
+ */
49
+ export function writeLocalProperties(root, sdk) {
50
+ writeFileSync(localPropertiesPath(root), `sdk.dir=${sdk}\n`)
51
+ }
52
+
53
+ /** @param {string} sdk */
54
+ export function hasCompilePlatform(sdk) {
55
+ const platforms = path.join(sdk, 'platforms')
56
+ if (!existsSync(platforms)) return false
57
+ return readdirSync(platforms).some((name) => name === `android-${ANDROID_COMPILE_SDK}` || name.startsWith(`android-${ANDROID_COMPILE_SDK}.`))
58
+ }
59
+
60
+ /** @param {string} sdk */
61
+ export function sdkManager(sdk) {
62
+ const bundled = path.join(sdk, 'cmdline-tools', 'latest', 'bin', 'sdkmanager')
63
+ return existsSync(bundled) ? bundled : 'sdkmanager'
64
+ }
65
+
66
+ /** @param {string} sdk */
67
+ export function adb(sdk) {
68
+ const bundled = path.join(sdk, 'platform-tools', 'adb')
69
+ return existsSync(bundled) ? bundled : 'adb'
70
+ }
71
+
72
+ /** @param {string} sdk */
73
+ export function emulator(sdk) {
74
+ const bundled = path.join(sdk, 'emulator', 'emulator')
75
+ return existsSync(bundled) ? bundled : 'emulator'
76
+ }
77
+
78
+ // ---------- iOS ----------
79
+
80
+ /**
81
+ * @typedef {{ udid: string, name: string, state: string, runtime: string, version: number[] }} Simulator
82
+ */
83
+
84
+ /**
85
+ * Picks the simulator to build and test on: GEEKNATIVE_IOS_SIMULATOR (name or UDID) if set,
86
+ * otherwise a booted iPhone, otherwise an iPhone on the newest installed iOS runtime.
87
+ * @returns {Simulator | null}
88
+ */
89
+ export function pickSimulator() {
90
+ const json = capture('xcrun', ['simctl', 'list', 'devices', 'available', '--json'])
91
+ if (!json) return null
92
+ /** @type {Simulator[]} */
93
+ const all = []
94
+ for (const [runtime, devices] of Object.entries(JSON.parse(json).devices ?? {})) {
95
+ const match = runtime.match(/iOS-(\d+)-(\d+)(?:-(\d+))?$/)
96
+ if (!match) continue
97
+ const version = match.slice(1).filter(Boolean).map(Number)
98
+ for (const device of /** @type {any[]} */ (devices)) {
99
+ if (device.isAvailable === false) continue
100
+ all.push({ udid: device.udid, name: device.name, state: device.state, runtime: `iOS ${version.join('.')}`, version })
101
+ }
102
+ }
103
+ const wanted = process.env.GEEKNATIVE_IOS_SIMULATOR
104
+ if (wanted) return all.find((sim) => sim.udid === wanted || sim.name === wanted) ?? null
105
+ const iphones = all.filter((sim) => sim.name.startsWith('iPhone'))
106
+ const booted = iphones.find((sim) => sim.state === 'Booted')
107
+ if (booted) return booted
108
+ iphones.sort((a, b) => compareVersions(b.version, a.version))
109
+ return iphones[0] ?? null
110
+ }
111
+
112
+ /** @param {number[]} a @param {number[]} b */
113
+ function compareVersions(a, b) {
114
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
115
+ const diff = (a[i] ?? 0) - (b[i] ?? 0)
116
+ if (diff) return diff
117
+ }
118
+ return 0
119
+ }
120
+
121
+ /** Xcode's major version, or null when Xcode isn't installed. */
122
+ export function xcodeMajorVersion() {
123
+ const out = capture('xcodebuild', ['-version'])
124
+ const match = out?.match(/^Xcode (\d+)/m)
125
+ return match ? Number(match[1]) : null
126
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: android-engineer
3
+ description: Implements and verifies changes in the Android app (apps/android, Kotlin and Jetpack Compose). Use for the Android part of any change to the apps, in parallel with ios-engineer and web-engineer.
4
+ ---
5
+
6
+ You build the Android app in apps/android. You're one of three platform engineers implementing the same spec; the lead agent coordinates.
7
+
8
+ Your brief from the lead has the spec path, the acceptance criteria IDs and what changed in contracts/. The Android conventions are in apps/android/AGENTS.md (given to you at start).
9
+
10
+ 1. Read the spec. Look at an existing feature (features/settings) and follow the same patterns.
11
+ 2. Implement from the spec. Don't port code from apps/ios or apps/web.
12
+ 3. Edit only files under apps/android/. Never edit spec/, contracts/, PRODUCT.md, DESIGN.md or generated files. If the spec is ambiguous or missing something, build what's clear and report the gap instead of guessing.
13
+ 4. Add or update tests named after the acceptance criteria: ``fun `AC-<id>-<n> ...`()``.
14
+ 5. Run `make check-android` until it passes.
15
+ 6. Report: the files you changed, the acceptance criteria your tests cover, the check result, and any spec gaps or questions.
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: ios-engineer
3
+ description: Implements and verifies changes in the iOS app (apps/ios, SwiftUI). Use for the iOS part of any change to the apps, in parallel with android-engineer and web-engineer.
4
+ ---
5
+
6
+ You build the iOS app in apps/ios. You're one of three platform engineers implementing the same spec; the lead agent coordinates.
7
+
8
+ Your brief from the lead has the spec path, the acceptance criteria IDs and what changed in contracts/. The iOS conventions are in apps/ios/AGENTS.md (given to you at start).
9
+
10
+ 1. Read the spec. Look at an existing feature (App/Features/Settings) and follow the same patterns.
11
+ 2. Implement from the spec. Don't port code from apps/android or apps/web.
12
+ 3. Edit only files under apps/ios/. Never edit spec/, contracts/, PRODUCT.md, DESIGN.md or generated files. If the spec is ambiguous or missing something, build what's clear and report the gap instead of guessing.
13
+ 4. Add or update tests named after the acceptance criteria: `@Test("AC-<id>-<n>: ...")`.
14
+ 5. Run `make check-ios` until it passes.
15
+ 6. Report: the files you changed, the acceptance criteria your tests cover, the check result, and any spec gaps or questions.
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: web-engineer
3
+ description: Implements and verifies changes in the web app (apps/web, React with TanStack Start). Use for the web part of any change to the apps, in parallel with ios-engineer and android-engineer.
4
+ ---
5
+
6
+ You build the web app in apps/web. You're one of three platform engineers implementing the same spec; the lead agent coordinates.
7
+
8
+ Your brief from the lead has the spec path, the acceptance criteria IDs and what changed in contracts/. The web conventions are in apps/web/AGENTS.md (given to you at start).
9
+
10
+ 1. Read the spec. Look at an existing feature (src/features/settings) and follow the same patterns.
11
+ 2. Implement from the spec. Don't port code from apps/ios or apps/android.
12
+ 3. Edit only files under apps/web/. Never edit spec/, contracts/, PRODUCT.md, DESIGN.md or generated files. If the spec is ambiguous or missing something, build what's clear and report the gap instead of guessing.
13
+ 4. Add or update tests named after the acceptance criteria: `test('AC-<id>-<n>: ...')`.
14
+ 5. Run `make check-web` until it passes.
15
+ 6. Report: the files you changed, the acceptance criteria your tests cover, the check result, and any spec gaps or questions.
@@ -0,0 +1,65 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(make *)",
5
+ "Bash(npx geeknative *)",
6
+ "Bash(./node_modules/.bin/geeknative *)",
7
+ "Bash(npm run *)",
8
+ "Bash(xcodebuild *)",
9
+ "Bash(xcrun simctl *)",
10
+ "Bash(./gradlew *)"
11
+ ]
12
+ },
13
+ "hooks": {
14
+ "PostToolUse": [
15
+ {
16
+ "matcher": "Edit|Write|MultiEdit",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node \"$CLAUDE_PROJECT_DIR/.geeknative/hooks/after-edit.mjs\""
21
+ }
22
+ ]
23
+ }
24
+ ],
25
+ "SubagentStart": [
26
+ {
27
+ "matcher": "ios-engineer",
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "node \"$CLAUDE_PROJECT_DIR/.geeknative/hooks/subagent-context.mjs\" ios"
32
+ }
33
+ ]
34
+ },
35
+ {
36
+ "matcher": "android-engineer",
37
+ "hooks": [
38
+ {
39
+ "type": "command",
40
+ "command": "node \"$CLAUDE_PROJECT_DIR/.geeknative/hooks/subagent-context.mjs\" android"
41
+ }
42
+ ]
43
+ },
44
+ {
45
+ "matcher": "web-engineer",
46
+ "hooks": [
47
+ {
48
+ "type": "command",
49
+ "command": "node \"$CLAUDE_PROJECT_DIR/.geeknative/hooks/subagent-context.mjs\" web"
50
+ }
51
+ ]
52
+ }
53
+ ],
54
+ "Stop": [
55
+ {
56
+ "hooks": [
57
+ {
58
+ "type": "command",
59
+ "command": "node \"$CLAUDE_PROJECT_DIR/.geeknative/hooks/parity-guard.mjs\""
60
+ }
61
+ ]
62
+ }
63
+ ]
64
+ }
65
+ }
@@ -0,0 +1,41 @@
1
+ # Managed by geeknative: replaced on upgrade. Add your own targets to the root Makefile.
2
+
3
+ GEEKNATIVE ?= ./node_modules/.bin/geeknative
4
+
5
+ .PHONY: help doctor gen check check-contracts check-ios check-android check-web parity run-ios run-android run-web
6
+
7
+ help: ## List the commands
8
+ @grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf " make %-16s %s\n", $$1, $$2}'
9
+
10
+ doctor: ## Check toolchains (FIX=1 installs what it can)
11
+ @$(GEEKNATIVE) doctor $(if $(FIX),--fix)
12
+
13
+ gen: ## Regenerate app code from contracts/
14
+ @$(GEEKNATIVE) gen
15
+
16
+ check: ## Validate contracts, then build and test all three apps
17
+ @$(GEEKNATIVE) check all
18
+
19
+ check-contracts: ## Validate contracts and check generated code is current
20
+ @$(GEEKNATIVE) check contracts
21
+
22
+ check-ios: ## Build and test the iOS app
23
+ @$(GEEKNATIVE) check ios
24
+
25
+ check-android: ## Build and test the Android app
26
+ @$(GEEKNATIVE) check android
27
+
28
+ check-web: ## Type-check, test and build the web app
29
+ @$(GEEKNATIVE) check web
30
+
31
+ parity: ## Update spec/parity.md from feature specs and tests
32
+ @$(GEEKNATIVE) parity
33
+
34
+ run-ios: ## Build and launch the iOS app in a simulator
35
+ @$(GEEKNATIVE) run ios
36
+
37
+ run-android: ## Build and launch the Android app on an emulator or device
38
+ @$(GEEKNATIVE) run android
39
+
40
+ run-web: ## Start the web dev server
41
+ @$(GEEKNATIVE) run web
@@ -0,0 +1,28 @@
1
+ // Claude Code PostToolUse hook (managed by geeknative).
2
+ // When a file in contracts/ is edited, regenerate app code right away, so the next build
3
+ // sees the change and mistakes in the contract are reported immediately.
4
+ import { spawnSync } from 'node:child_process'
5
+ import path from 'node:path'
6
+
7
+ let input = ''
8
+ for await (const chunk of process.stdin) input += chunk
9
+ const file = JSON.parse(input || '{}').tool_input?.file_path
10
+ const root = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
11
+
12
+ if (!file || !path.resolve(root, file).startsWith(path.join(root, 'contracts') + path.sep)) process.exit(0)
13
+
14
+ const result = spawnSync(path.join(root, 'node_modules', '.bin', 'geeknative'), ['gen'], { cwd: root, encoding: 'utf8' })
15
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
16
+
17
+ if (result.error || result.status !== 0) {
18
+ console.error(`\`make gen\` failed after editing ${path.relative(root, file)}. Fix the contract:\n${output || result.error}`)
19
+ process.exit(2)
20
+ }
21
+ console.log(
22
+ JSON.stringify({
23
+ hookSpecificOutput: {
24
+ hookEventName: 'PostToolUse',
25
+ additionalContext: `Contracts changed, so geeknative regenerated the app code:\n${output}`,
26
+ },
27
+ }),
28
+ )