Android project facts for the model: a nav_graph route index, Compose stability reports on demand, and the real manifest SDKs and permissions.

Four Claude Code mods for Android and Kotlin Multiplatform work. A mod is a plugin of function hooks: it changes what Claude Code shows and does, by drawing a band or pane, adding tools the model can call, or rewriting the model's tool calls before they run.
| Mod | What it gives you |
|---|---|
| build-output-distiller | Gradle output becomes a short digest: Kotlin, KSP, Hilt/Dagger, Room, AAPT, R8 and test failures as file:line lists with fix hints. Repeat failures are flagged. /build opens the errors in a pane. |
| android-logcat | Bare adb logcat calls get a bound (-d -t 800, or -t 200 --pid=<pid>) so they can't hang. Secrets are redacted from adb output. A pid-scoped buffer feeds a logcat_grep tool and a /logcat pane. |
| android-device-console | A band of live toggles above the prompt: dark mode, font scale, density, RTL, layout bounds, animations. /device adds Doze, standby bucket and scheduled jobs with Run buttons. Tools: set_device_state, set_doze, run_worker. The system prompt states the current device state. |
| android-project-lens | A nav_graph tool that indexes @Serializable routes and where nav hosts register them. /stability gives Compose stability reports on demand. The system prompt gets the real targetSdk, minSdk and permissions. Claude asks before adding Play-declaration permissions. |
The mods don't overlap: only build-output-distiller hooks Gradle commands, and only android-logcat hooks adb logcat.
claude --version.adb on your PATH, or ANDROID_HOME / ANDROID_SDK_ROOT set, for the three mods that talk to a device.Install from a terminal session. Each mod installs on its own, so install only the ones you want.
At the Claude Code prompt:
/plugin install android-device-console --marketplace NathanWA/claude-android-mods
/plugin install android-logcat --marketplace NathanWA/claude-android-mods
/plugin install build-output-distiller --marketplace NathanWA/claude-android-mods
/plugin install android-project-lens --marketplace NathanWA/claude-android-mods
The first install asks Add marketplace?: answer y. Then choose a scope; the user scope is first and is the usual choice. The mod is active in that session right away and in every session you start after.
From a shell:
claude plugin install android-device-console --marketplace NathanWA/claude-android-mods
In the Claude desktop app, /plugin doesn't run in the Code tab. Click + next to the prompt box, then Plugins, then Add marketplace with NathanWA/claude-android-mods, then Add plugin. A mod you install from a terminal at user scope also loads in the desktop app's local sessions.
To update later:
claude plugin update android-device-console
There's nothing to start. Open Claude Code in your Android project and the mods work on their own:
./gradlew assembleDebug, testDebugUnitTest). The model reads a digest instead of the raw log. Run /build to see the last build's errors in a pane.adb logcat, the call is bounded and redacted. The model can search the buffer with logcat_grep. Run /logcat to see the buffer. The buffer follows the app when it restarts and gets a new pid, and logcat_grep warns if that pid looks stale./device to open the console. After the first read, the band of toggles stays above the prompt; press 1–6 once the band has focus. Ask Claude things like "screenshot the settings screen in dark mode at font scale 1.3": it calls set_device_state itself. /device reset restores the defaults.nav_graph. Run /stability to compile once with Compose compiler reports, or /stability read to read existing reports.Several mods run adb through Claude Code's $.process, which is documented as CLI-only. Where it isn't available, as in the desktop app, they fall back to the Bash tool, and each call asks for permission. To stop the prompts, add this to permissions.allow in ~/.claude/settings.json:
"Bash(adb *)"
The mods never poll through the Bash tool. In the desktop app, device state is re-read only when you run a command, press a button, or the model calls a tool.
android-device-console picks its target in this order: ANDROID_SERIAL, then the device you picked in /device, then the only connected device. When several devices are connected and none is chosen, it asks rather than guesses.
android-device-console has two options, which you can set in /config or under pluginConfigs in settings:
package: the applicationId. When empty, it is read from app/build.gradle(.kts).pollSeconds: how often device state is refreshed when $.process works. The default is 15; 0 turns refreshing off.These mods are early. Each passes claude plugin validate and its own claude plugin test suite, but they have had little use on real projects and devices. Known gaps:
dumpsys output differs between API levels. The parsers read it defensively, but untested API levels may show ?.debug.force_rtl, which may need a reboot. Layout bounds use a service call that some system images ignore./stability Gradle init script has not yet been run against every AGP and Kotlin version.Issues and pull requests are welcome.
Clone the repository and load a mod from disk for one session:
claude --plugin-dir plugins/android-device-console
Check and test a mod:
claude plugin validate plugins/android-device-console
claude plugin test plugins/android-device-console
Each mod's tsconfig.json extends the types Claude Code writes into .claude-plugin/types/ when it loads the mod. After one load, tsc -p plugins/<mod> type-checks it.
hooks/register.ts 252 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LensManifest, LensNavIndex, LensStability } from '../types'
5
6import { addedPermissions, describeManifest, playDeclaration } from './manifest'
7import { describeGraph, routeKey, unregisteredRoutes } from './nav'
8import { absolute, gatherManifest, indexNav, join, loadStability, refreshNav, relative, reportIndexPath, scanProject } from './project'
9import type { Project } from './project'
10import { EMPTY_STABILITY, describeStability, factsForEdit } from './stability'
11
12const PLUGIN = 'android-project-lens'
13
14const rootA = atom({ plugin: 'android-project-lens', key: 'root' } as const, '')
15const navA = atom({ plugin: 'android-project-lens', key: 'nav' } as const, { files: {}, indexedAt: 0 } as LensNavIndex)
16const dirtyA = atom({ plugin: 'android-project-lens', key: 'dirtyKt' } as const, [] as string[])
17const reportedA = atom({ plugin: 'android-project-lens', key: 'reportedUnregistered' } as const, [] as string[])
18const stabilityA = atom({ plugin: 'android-project-lens', key: 'stability' } as const, EMPTY_STABILITY as LensStability)
19const manifestA = atom({ plugin: 'android-project-lens', key: 'manifest' } as const, null as LensManifest | null)
20
21function shellQuote(s: string): string {
22 return `'${s.replace(/'/g, `'\\''`)}'`
23}
24
25// The walk's result, for re-reading manifests; session.start runs again on a reload.
26let project: Project | null = null
27
28/** The route index with every .kt file edited since the last read parsed again. */
29async function freshNav($: EngineInterface): Promise<LensNavIndex> {
30 const root = await read($, rootA)
31 const dirty = await read($, dirtyA)
32 const nav = await read($, navA)
33 if (dirty.length === 0) return nav
34 const next = await refreshNav($, root, nav, dirty, await $.clock.now())
35 await update($, navA, () => next)
36 await update($, dirtyA, list => list.filter(p => !dirty.includes(p)))
37 return next
38}
39
40async function refreshManifest($: EngineInterface): Promise<void> {
41 const root = await read($, rootA)
42 if (root.length === 0) return
43 project = project ?? (await scanProject($, root))
44 const facts = await gatherManifest($, project)
45 await update($, manifestA, () => facts)
46}
47
48export const register: Register = on => {
49 on('session.start', async ($, e, next) => {
50 const started = await next(e)
51 const root = e.cwd
52 const settings = await Promise.all(
53 ['settings.gradle.kts', 'settings.gradle'].map(n => $.fs.exists(join(root, n)).catch(() => false)),
54 )
55 if (!settings.some(Boolean)) return started
56
57 await update($, rootA, () => root)
58 try {
59 await $.tool.register({
60 name: 'nav_graph',
61 description:
62 "This Android project's navigation routes, read from the source: every @Serializable route type, its constructor arguments, " +
63 'where it is declared and where a nav host registers it (Navigation 2 composable<X>/dialog<X>/navigation<X>, Navigation 3 entry<X>). ' +
64 'Use it instead of grepping for routes. Pass `route` (any part of a route name) for one route; leave it out for the whole graph, ' +
65 'which also flags routes no nav host registers.',
66 inputSchema: {
67 type: 'object',
68 properties: { route: { type: 'string', description: 'Part of a route name, such as "Detail" or "Route.Settings".' } },
69 },
70 isDeferred: false,
71 })
72 } catch (err) {
73 $.ui.log(`${PLUGIN}: nav_graph tool not registered: ${String(err)}`, { to: 'debug' })
74 }
75 try {
76 await $.command.register({
77 name: 'stability',
78 description: 'Compile once with Compose compiler reports and list composables with unstable parameters',
79 argumentHint: '[gradle task, default compileDebugKotlin | read]',
80 })
81 } catch (err) {
82 $.ui.log(`${PLUGIN}: /stability not registered: ${String(err)}`, { to: 'debug' })
83 }
84
85 const now = await $.clock.now()
86 project = await scanProject($, root)
87 const nav = await indexNav($, project, now)
88 await update($, navA, () => nav)
89 await update($, dirtyA, () => [])
90 // Routes already unregistered are the baseline: a turn reports only new ones.
91 await update($, reportedA, () => unregisteredRoutes(nav).map(routeKey))
92 const facts = await gatherManifest($, project)
93 await update($, manifestA, () => facts)
94 const { table } = await loadStability($, root, await read($, stabilityA))
95 await update($, stabilityA, () => table)
96 return started
97 })
98
99 // ---- Nav --------------------------------------------------------------
100
101 on('tool.call', { tool: 'mcp__android-project-lens__nav_graph' }, async ($, e) => {
102 const nav = await freshNav($)
103 const route = typeof e['route'] === 'string' && e['route'].length > 0 ? e['route'] : undefined
104 return { result: describeGraph(nav, route) }
105 }).catch(() => ({ result: `${PLUGIN}: the route index could not be read this time; search the sources for @Serializable routes instead.` }))
106
107 on('turn.complete', async ($, e, next) => {
108 const done = await next(e)
109 if (e.agentId !== undefined) return done
110 const root = await read($, rootA)
111 if (root.length === 0) return done
112
113 const nav = await freshNav($)
114 const unregistered = unregisteredRoutes(nav)
115 const before = await read($, reportedA)
116 const fresh = unregistered.filter(r => !before.includes(routeKey(r)))
117 await update($, reportedA, () => unregistered.map(routeKey))
118 await refreshManifest($)
119
120 if (fresh.length > 0) {
121 const text =
122 `${PLUGIN}: ${fresh.length === 1 ? 'a route is' : `${fresh.length} routes are`} declared but no nav host registers ` +
123 `${fresh.length === 1 ? 'it' : 'them'} (no composable<X>, dialog<X>, navigation<X> or entry<X>): ` +
124 `${fresh.map(r => `${r.qualified} (${r.file}:${r.line})`).join(', ')}. Navigating to an unregistered route crashes at runtime.`
125 $.ui.log(text)
126 try {
127 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
128 } catch {
129 // the log line above still tells the person
130 }
131 }
132 return done
133 })
134
135 // ---- Edits of Kotlin files: nav refresh and stability facts ------------
136
137 on('tool.call', { tool: ['Edit', 'Write'], file_path: /\.kt$/ }, async ($, e, next) => {
138 const ran = await next(e)
139 if (ran.deny !== undefined || ran.isError === true) return ran
140 const root = await read($, rootA)
141 if (root.length === 0) return ran
142
143 const path = relative(root, absolute(root, e.file_path))
144 await update($, dirtyA, list => (list.includes(path) ? list : [...list, path]))
145
146 let table = await read($, stabilityA)
147 try {
148 const st = await $.fs.stat(reportIndexPath(root))
149 if (st.mtimeMs !== table.indexMtime) {
150 table = (await loadStability($, root, table)).table
151 await update($, stabilityA, () => table)
152 }
153 } catch {
154 // no /stability run in this project yet
155 }
156 const facts = factsForEdit(table, e.tool === 'Edit' ? e.new_string : e.content)
157 if (facts.length === 0) return ran
158 return { ...ran, context: [...(ran.context ?? []), ...facts] }
159 }).catch(($, e, next) => next(e))
160
161 // ---- Stability --------------------------------------------------------
162
163 on('command.run', { command: 'stability' }, async ($, e) => {
164 const root = await read($, rootA)
165 if (root.length === 0) return { text: 'android-project-lens: this session is not in a Gradle project.' }
166 const args = e.args.trim()
167 const notes: string[] = []
168
169 if (args !== 'read') {
170 const tasks = args.length > 0 ? args : 'compileDebugKotlin'
171 if (!/^[\w:.\- ]+$/.test(tasks)) return { text: `Not a Gradle task list: ${tasks}` }
172 const init = `${$.plugin.root}/gradle/compose-reports.init.gradle`
173 const command =
174 `cd ${shellQuote(root)} && ./gradlew ${tasks} --init-script ${shellQuote(init)} ` +
175 '-Pkotlin.incremental=false -Pkotlin.incremental.multiplatform=false --no-configuration-cache --console=plain'
176 let ran
177 try {
178 ran = await $.tool.call({ tool: 'Bash', command, description: 'Compile with Compose compiler reports (/stability)', timeout: 600_000 })
179 } catch (err) {
180 ran = { deny: String(err) }
181 }
182 if (ran.deny !== undefined) {
183 return { text: `The build did not run (${ran.deny}). Run it yourself, then /stability read:\n${command}` }
184 }
185 if (ran.isError === true) notes.push('The build reported an error; the reports cover what compiled.')
186 }
187
188 const { table, reportFiles } = await loadStability($, root, await read($, stabilityA))
189 await update($, stabilityA, () => table)
190 if (reportFiles === 0) {
191 return {
192 text:
193 'No Compose compiler reports found. /stability needs the Kotlin Compose compiler Gradle plugin (Kotlin 2.0+) ' +
194 'and a task that compiles Compose code; pass one, such as `/stability :app:compileDebugKotlin` or `compileDebugKotlinAndroid` for KMP.',
195 }
196 }
197 return { text: [...notes, describeStability(table)].join('\n') }
198 })
199
200 // ---- Manifest ---------------------------------------------------------
201
202 on('prompt.compose', async ($, e, next) => {
203 const composed = await next(e)
204 const facts = await read($, manifestA)
205 if (facts === null) return composed
206 return {
207 sections: [...composed.sections, { id: `${PLUGIN}:manifest`, text: describeManifest(facts), scope: 'session' as const }],
208 }
209 })
210
211 // Judged before next: a deny after the edit ran would undo nothing.
212 on('tool.call', { tool: ['Edit', 'Write'], file_path: /(^|\/)AndroidManifest\.xml$/ }, async ($, e, next) => {
213 let before = ''
214 let after: string
215 if (e.tool === 'Write') {
216 before = await $.fs.read(e.file_path).then(t => (typeof t === 'string' ? t : ''), () => '')
217 after = e.content
218 } else {
219 const text = await $.fs.read(e.file_path).then(t => (typeof t === 'string' ? t : null), () => null)
220 if (text === null || !text.includes(e.old_string)) return next(e) // the Edit fails on its own
221 before = text
222 after = e.replace_all === true ? text.split(e.old_string).join(e.new_string) : text.replace(e.old_string, () => e.new_string)
223 }
224
225 const needsDeclaration = addedPermissions(before, after).filter(p => playDeclaration(p) !== undefined)
226 if (needsDeclaration.length > 0) {
227 const names = needsDeclaration.map(p => p.replace(/^android\.permission\./, ''))
228 const why = needsDeclaration.map(p => `${p.replace(/^android\.permission\./, '')}: ${playDeclaration(p)}`).join('; ')
229 let answer: string
230 try {
231 answer = await $.ui.ask(
232 `This edit adds ${names.join(', ')} to ${relative(await read($, rootA), e.file_path)}. Google Play needs a Play Console declaration for ${names.length === 1 ? 'it' : 'them'} (${why}). Add ${names.length === 1 ? 'it' : 'them'}?`,
233 { options: ['Add it', 'Leave it out'], header: 'Permission' },
234 )
235 } catch {
236 return { deny: `${PLUGIN}: adding ${names.join(', ')} needs a Play Console declaration and the question was dismissed; ask the person before adding it.` }
237 }
238 if (answer !== 'Add it') {
239 return {
240 deny: `${PLUGIN}: the person chose not to add ${names.join(', ')} (answer: ${answer}). Find an approach that does not need ${names.length === 1 ? 'it' : 'them'}.`,
241 }
242 }
243 }
244
245 const ran = await next(e)
246 if (ran.deny === undefined && ran.isError !== true) await refreshManifest($)
247 return ran
248 }).catch(($, e, next) =>
249 next.called ? next(e) : { deny: `${PLUGIN}: its manifest permission check failed, so this manifest edit was held back. Try again, or edit it by hand.` },
250 )
251}
252hooks/manifest.ts 197 lines1// AndroidManifest.xml without an XML parser: the sandbox has none, and a
2// manifest is regular enough for a hand tokenizer.
3
4import type { LensManifest, LensPermission } from '../types'
5
6export type XmlElement = { name: string; attrs: Record<string, string>; line: number }
7
8const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
9
10function decode(value: string): string {
11 return value.replace(/&(#x[0-9a-fA-F]+|#\d+|\w+);/g, (whole, ref: string) => {
12 if (ref.startsWith('#x')) return String.fromCodePoint(parseInt(ref.slice(2), 16))
13 if (ref.startsWith('#')) return String.fromCodePoint(parseInt(ref.slice(1), 10))
14 return ENTITIES[ref] ?? whole
15 })
16}
17
18/** Every start (or empty) element of the document, in order, with its attributes. */
19export function tokenizeXml(src: string): XmlElement[] {
20 const elements: XmlElement[] = []
21 let i = 0
22 let line = 1
23 const advance = (to: number) => {
24 for (let k = i; k < to && k < src.length; k++) if (src[k] === '\n') line++
25 i = to
26 }
27 while (i < src.length) {
28 const lt = src.indexOf('<', i)
29 if (lt === -1) break
30 advance(lt)
31 if (src.startsWith('<!--', i)) {
32 const end = src.indexOf('-->', i + 4)
33 advance(end === -1 ? src.length : end + 3)
34 continue
35 }
36 if (src.startsWith('<![CDATA[', i)) {
37 const end = src.indexOf(']]>', i)
38 advance(end === -1 ? src.length : end + 3)
39 continue
40 }
41 if (src.startsWith('<?', i) || src.startsWith('<!', i) || src.startsWith('</', i)) {
42 const end = src.indexOf('>', i)
43 advance(end === -1 ? src.length : end + 1)
44 continue
45 }
46 // A start tag: find its end outside quotes.
47 let j = i + 1
48 let quote: string | null = null
49 while (j < src.length) {
50 const c = src[j]
51 if (quote !== null) {
52 if (c === quote) quote = null
53 } else if (c === '"' || c === "'") quote = c
54 else if (c === '>') break
55 j++
56 }
57 const body = src.slice(i + 1, j).replace(/\/\s*$/, '')
58 const nameMatch = /^([\w:.-]+)/.exec(body)
59 if (nameMatch !== null) {
60 const attrs: Record<string, string> = {}
61 for (const a of body.slice(nameMatch[0].length).matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
62 attrs[a[1] as string] = decode(a[2] ?? a[3] ?? '')
63 }
64 elements.push({ name: nameMatch[1] as string, attrs, line })
65 }
66 advance(j + 1)
67 }
68 return elements
69}
70
71const PERMISSION_TAGS = new Set(['uses-permission', 'uses-permission-sdk-23', 'uses-permission-sdk-m'])
72
73/** The permissions a manifest asks for, and those it removes with tools:node="remove". */
74export function permissionsOf(xml: string): { declared: string[]; removed: string[] } {
75 const declared = new Set<string>()
76 const removed = new Set<string>()
77 for (const el of tokenizeXml(xml)) {
78 if (!PERMISSION_TAGS.has(el.name)) continue
79 const name = el.attrs['android:name']
80 if (name === undefined || name.length === 0) continue
81 if (el.attrs['tools:node'] === 'remove' || el.attrs['tools:node'] === 'removeAll') removed.add(name)
82 else declared.add(name)
83 }
84 return { declared: [...declared], removed: [...removed] }
85}
86
87/** `<uses-sdk>` of a (merged) manifest. */
88export function usesSdk(xml: string): { minSdk?: string; targetSdk?: string } {
89 const el = tokenizeXml(xml).find(e => e.name === 'uses-sdk')
90 if (el === undefined) return {}
91 const out: { minSdk?: string; targetSdk?: string } = {}
92 const min = el.attrs['android:minSdkVersion']
93 const target = el.attrs['android:targetSdkVersion']
94 if (min !== undefined) out.minSdk = min
95 if (target !== undefined) out.targetSdk = target
96 return out
97}
98
99/** Permissions the edit adds: in `after` and not in `before`. */
100export function addedPermissions(before: string, after: string): string[] {
101 const was = new Set(permissionsOf(before).declared)
102 return permissionsOf(after).declared.filter(p => !was.has(p))
103}
104
105/**
106 * Permissions Google Play asks a declaration for in the Play Console before
107 * the app can ship with them, and why.
108 */
109const PLAY_DECLARATIONS: Record<string, string> = {
110 'android.permission.ACCESS_BACKGROUND_LOCATION': 'background location: Play Console location declaration and a video of the feature',
111 'android.permission.READ_SMS': 'SMS group: Play only allows it for default SMS handlers or listed exceptions',
112 'android.permission.SEND_SMS': 'SMS group: Play only allows it for default SMS handlers or listed exceptions',
113 'android.permission.RECEIVE_SMS': 'SMS group: Play only allows it for default SMS handlers or listed exceptions',
114 'android.permission.RECEIVE_MMS': 'SMS group: Play only allows it for default SMS handlers or listed exceptions',
115 'android.permission.RECEIVE_WAP_PUSH': 'SMS group: Play only allows it for default SMS handlers or listed exceptions',
116 'android.permission.READ_CALL_LOG': 'Call Log group: Play only allows it for default phone handlers or listed exceptions',
117 'android.permission.WRITE_CALL_LOG': 'Call Log group: Play only allows it for default phone handlers or listed exceptions',
118 'android.permission.PROCESS_OUTGOING_CALLS': 'Call Log group: Play only allows it for default phone handlers or listed exceptions',
119 'android.permission.MANAGE_EXTERNAL_STORAGE': 'All files access: Play declaration; most apps must use MediaStore or SAF instead',
120 'android.permission.QUERY_ALL_PACKAGES': 'package visibility: Play declaration; a <queries> element usually suffices',
121 'android.permission.REQUEST_INSTALL_PACKAGES': 'installing packages: Play declaration of the core feature that needs it',
122 'android.permission.READ_MEDIA_IMAGES': 'photo and video permissions: Play declaration; one-time access through the photo picker needs none',
123 'android.permission.READ_MEDIA_VIDEO': 'photo and video permissions: Play declaration; one-time access through the photo picker needs none',
124 'android.permission.USE_EXACT_ALARM': 'exact alarms: Play only allows it for alarm and calendar apps; SCHEDULE_EXACT_ALARM or inexact alarms otherwise',
125 'android.permission.USE_FULL_SCREEN_INTENT': 'full-screen intents: Play declaration for apps targeting Android 14+, limited to calls and alarms',
126 'android.permission.BIND_ACCESSIBILITY_SERVICE': 'accessibility API: Play declaration and prominent disclosure',
127 'android.permission.BIND_VPN_SERVICE': 'VpnService: Play declaration of the VPN core feature',
128}
129
130/** Why Play needs a declaration for this permission, or undefined when it needs none. */
131export function playDeclaration(permission: string): string | undefined {
132 const known = PLAY_DECLARATIONS[permission]
133 if (known !== undefined) return known
134 if (permission.startsWith('android.permission.FOREGROUND_SERVICE_')) {
135 return 'foreground service type: Play Console foreground service declaration for apps targeting Android 14+'
136 }
137 if (permission.startsWith('android.permission.health.')) return 'Health Connect data: Play Console health permissions declaration'
138 return undefined
139}
140
141/**
142 * Attributions from a manifest-merger report: permission -> the library
143 * coordinates (or file paths) it was merged from. Unofficial AGP format, so
144 * anything unrecognised is left out rather than guessed.
145 */
146export function parseMergerReport(text: string): Record<string, string[]> {
147 const out: Record<string, string[]> = {}
148 let current: string | null = null
149 for (const line of text.split('\n')) {
150 const head = /^uses-permission(?:-sdk-23|-sdk-m)?#(\S+)/.exec(line)
151 if (head !== null) {
152 current = head[1] as string
153 out[current] = out[current] ?? []
154 continue
155 }
156 if (/^\S/.test(line) && !/^(ADDED|MERGED|INJECTED|IMPLIED|REJECTED)\s/.test(line)) {
157 current = null
158 continue
159 }
160 if (current === null) continue
161 const from = /^(?:ADDED|MERGED|IMPLIED)\s+from\s+(?:\[([^\]]+)\]|(\S+))/.exec(line.trim())
162 if (from === null) continue
163 const origin = from[1] ?? from[2] ?? ''
164 const list = out[current] as string[]
165 if (origin.length > 0 && !list.includes(origin)) list.push(origin)
166 }
167 return out
168}
169
170function short(permission: string): string {
171 return permission.replace(/^android\.permission\./, '')
172}
173
174function group(list: readonly LensPermission[]): string {
175 const byOrigin = new Map<string, string[]>()
176 for (const p of list) byOrigin.set(p.origin, [...(byOrigin.get(p.origin) ?? []), short(p.name)])
177 return [...byOrigin].map(([origin, names]) => `${names.sort().join(', ')} (${origin})`).join('; ')
178}
179
180/** The system prompt section. */
181export function describeManifest(m: LensManifest): string {
182 const lines = ['Android project facts (android-project-lens; permission names drop the android.permission. prefix):']
183 const sdk: string[] = []
184 if (m.targetSdk !== undefined) sdk.push(`targetSdk ${m.targetSdk} (${m.targetSdkFrom ?? 'unknown source'})`)
185 if (m.minSdk !== undefined) sdk.push(`minSdk ${m.minSdk} (${m.minSdkFrom ?? 'unknown source'})`)
186 lines.push(sdk.length > 0 ? `- ${sdk.join(', ')}` : '- targetSdk and minSdk: not found (no merged manifest yet and no literal in the build files)')
187 lines.push(`- Permissions the app module declares: ${m.app.length > 0 ? group(m.app) : 'none'}`)
188 if (m.modules.length > 0) lines.push(`- Permissions other project modules declare: ${group(m.modules)}`)
189 if (m.merged !== undefined) {
190 lines.push(`- Permissions merged in from libraries: ${m.libraries.length > 0 ? group(m.libraries) : 'none'} (from ${m.merged.path})`)
191 } else {
192 lines.push('- Library permissions: unknown until the app is built once (no merged manifest under build/intermediates).')
193 }
194 if (m.removed.length > 0) lines.push(`- Removed with tools:node="remove": ${m.removed.map(short).sort().join(', ')}`)
195 return lines.join('\n')
196}
197hooks/nav.ts 224 lines1// The route index: @Serializable route types and the nav-host calls that
2// register them, Navigation 2 (`composable<X>`) and Navigation 3 (`entry<X>`).
3
4import type { LensNavFile, LensNavIndex, LensRegistration, LensRoute, LensRouteArg } from '../types'
5
6import { blankCommentsAndStrings, closeOf, lineAt, lineStarts, splitTopLevel } from './kotlin'
7
8/** Text worth parsing: anything without these markers holds no route. */
9export const NAV_MARKERS = /@(?:kotlinx\.serialization\.)?Serializable|\b(?:composable|dialog|navigation|entry|bottomSheet)\s*<|NavEntry\s*\(/
10
11const ROUTE_NAME = /(Route|Routes|Screen|Screens|Destination|Destinations|Dest|Graph|NavKey|Nav)$/
12const MODIFIERS = new Set(['data', 'sealed', 'internal', 'private', 'public', 'protected', 'open', 'abstract', 'value', 'inline', 'enum', 'expect', 'actual'])
13
14const TOKEN = /\b(class|interface|object)\b(?:\s+([A-Za-z_]\w*))?|\b(fun|val|var)\b|[(){}]/g
15const REGISTRATION = /\b(composable|dialog|navigation|entry|bottomSheet)\s*<\s*([A-Za-z_][\w.]*)\s*>/g
16const NAV3_WHEN = /\bis\s+([A-Za-z_][\w.]*)\s*->\s*NavEntry\b/g
17
18/** Parses one Kotlin file's routes and registrations. */
19export function parseNavFile(path: string, src: string): LensNavFile {
20 const text = blankCommentsAndStrings(src)
21 const starts = lineStarts(src)
22 const routes: LensRoute[] = []
23 const registrations: LensRegistration[] = []
24
25 // Containers: a stack of the declarations whose bodies we are inside.
26 const stack: string[] = []
27 let pending: string | null = null
28 let parens = 0
29 let boundary = 0
30
31 for (const m of text.matchAll(TOKEN)) {
32 const at = m.index ?? 0
33 const tok = m[0]
34 if (tok === '(') {
35 parens++
36 continue
37 }
38 if (tok === ')') {
39 parens = Math.max(0, parens - 1)
40 if (parens === 0) boundary = at + 1
41 continue
42 }
43 if (tok === '{') {
44 stack.push(pending ?? '')
45 pending = null
46 boundary = at + 1
47 continue
48 }
49 if (tok === '}') {
50 stack.pop()
51 pending = null
52 boundary = at + 1
53 continue
54 }
55 if (parens > 0) continue
56 if (m[3] !== undefined) {
57 pending = ''
58 continue
59 }
60 const kind = m[1] as 'class' | 'interface' | 'object'
61 const name = m[2]
62 pending = name ?? ''
63 const before = text.slice(boundary, at)
64 boundary = at + tok.length
65 if (name === undefined || kind === 'interface') continue
66 if (!/@(?:kotlinx\.serialization\.)?Serializable\b/.test(leadingModifiers(before))) continue
67
68 // The primary constructor and supertypes.
69 let cursor = at + tok.length
70 while (text[cursor] === ' ' || text[cursor] === '\t') cursor++
71 if (text[cursor] === '<') cursor = Math.max(cursor, closeOf(text, cursor))
72 while (text[cursor] === ' ' || text[cursor] === '\t') cursor++
73 let args: LensRouteArg[] = []
74 if (kind === 'class' && text[cursor] === '(') {
75 const end = closeOf(text, cursor)
76 if (end !== -1) {
77 args = parseArgs(src.slice(cursor + 1, end - 1), text.slice(cursor + 1, end - 1))
78 cursor = end
79 }
80 }
81 const tail = /^\s*:\s*([^{;\n]*)/.exec(text.slice(cursor, cursor + 400))
82 const supertypes = tail?.[1] ?? ''
83 const containers = stack.filter(s => s.length > 0)
84 const isRoute =
85 ROUTE_NAME.test(name) ||
86 containers.some(c => ROUTE_NAME.test(c)) ||
87 /\bNavKey\b/.test(supertypes) ||
88 splitTopLevel(supertypes).some(s => ROUTE_NAME.test(s.replace(/\(.*$/s, '').replace(/<.*$/s, '').trim()))
89 if (!isRoute) continue
90 routes.push({
91 name,
92 qualified: [...containers, name].join('.'),
93 kind: kind === 'object' ? 'object' : 'class',
94 args,
95 file: path,
96 line: lineAt(starts, at),
97 })
98 }
99
100 for (const m of text.matchAll(REGISTRATION)) {
101 const target = m[2] as string
102 if (/^[A-Z]$/.test(target)) continue // a generic helper's own type parameter
103 registrations.push({ target, kind: m[1] as string, file: path, line: lineAt(starts, m.index ?? 0) })
104 }
105 if (/\bNavEntry\s*\(/.test(text)) {
106 for (const m of text.matchAll(NAV3_WHEN)) {
107 registrations.push({ target: m[1] as string, kind: 'entry', file: path, line: lineAt(starts, m.index ?? 0) })
108 }
109 }
110 return { routes, registrations }
111}
112
113const TRAILING_MODIFIER = new RegExp(`(?:@[\\w.]+(?:\\s*\\([^()]*\\))?|\\b(?:${[...MODIFIERS].join('|')}))\\s*$`)
114
115/** The annotations and modifiers written directly before a declaration keyword. */
116function leadingModifiers(before: string): string {
117 let rest = before.trimEnd()
118 let taken = ''
119 for (let m = TRAILING_MODIFIER.exec(rest); m !== null && m[0].length > 0; m = TRAILING_MODIFIER.exec(rest)) {
120 taken = m[0] + ' ' + taken
121 rest = rest.slice(0, m.index).trimEnd()
122 }
123 return taken
124}
125
126function parseArgs(original: string, blanked: string): LensRouteArg[] {
127 const args: LensRouteArg[] = []
128 // Split the blanked text so commas in strings do not split; slice the original.
129 let from = 0
130 const pieces: [number, number][] = []
131 let depth = 0
132 for (let i = 0; i <= blanked.length; i++) {
133 const c = blanked[i]
134 if (c === '(' || c === '[' || c === '{' || c === '<') depth++
135 else if (c === ')' || c === ']' || c === '}' || (c === '>' && blanked[i - 1] !== '-')) depth = Math.max(0, depth - 1)
136 if ((c === ',' && depth === 0) || i === blanked.length) {
137 pieces.push([from, i])
138 from = i + 1
139 }
140 }
141 for (const [a, b] of pieces) {
142 const piece = original.slice(a, b).replace(/@[\w.]+(\s*\([^)]*\))?/g, ' ').trim()
143 const m = /^(?:(?:private|internal|public|protected|override)\s+)*(?:val|var)?\s*([A-Za-z_]\w*)\s*:\s*([\s\S]+?)(?:\s*=\s*([\s\S]+))?$/.exec(piece)
144 if (m === null) continue
145 const arg: LensRouteArg = { name: m[1] as string, type: (m[2] as string).replace(/\s+/g, ' ').trim() }
146 if (m[3] !== undefined) arg.default = m[3].replace(/\s+/g, ' ').trim()
147 args.push(arg)
148 }
149 return args
150}
151
152/** Whether a registration's type argument names this route. */
153export function registers(target: string, route: LensRoute): boolean {
154 return (
155 target === route.qualified ||
156 target === route.name ||
157 route.qualified.endsWith('.' + target) ||
158 target.endsWith('.' + route.qualified)
159 )
160}
161
162export function allRoutes(index: LensNavIndex): LensRoute[] {
163 return Object.values(index.files).flatMap(f => f.routes)
164}
165
166export function allRegistrations(index: LensNavIndex): LensRegistration[] {
167 return Object.values(index.files).flatMap(f => f.registrations)
168}
169
170/** Routes no nav host registers, as `qualified@file`. */
171export function unregisteredRoutes(index: LensNavIndex): LensRoute[] {
172 const regs = allRegistrations(index)
173 return allRoutes(index).filter(r => !regs.some(g => registers(g.target, r)))
174}
175
176export function routeKey(route: LensRoute): string {
177 return `${route.qualified}@${route.file}`
178}
179
180function describeArgs(route: LensRoute): string {
181 if (route.kind === 'object') return ''
182 return `(${route.args.map(a => `${a.name}: ${a.type}${a.default !== undefined ? ` = ${a.default}` : ''}`).join(', ')})`
183}
184
185/** The nav_graph tool's answer, for one route query or the whole graph. */
186export function describeGraph(index: LensNavIndex, query?: string): string {
187 const routes = allRoutes(index)
188 const regs = allRegistrations(index)
189 const flavour = regs.some(r => r.kind === 'entry')
190 ? regs.some(r => r.kind !== 'entry')
191 ? 'Navigation 2 and Navigation 3'
192 : 'Navigation 3'
193 : regs.length > 0
194 ? 'Navigation 2'
195 : 'no registrations found'
196
197 const q = query?.trim().toLowerCase()
198 const picked = q ? routes.filter(r => r.qualified.toLowerCase().includes(q)) : routes
199 if (routes.length === 0) {
200 return 'No @Serializable route types found in this project (searched .kt files outside build/ and test source sets).'
201 }
202 if (picked.length === 0) {
203 return `No route matches "${query}". Routes: ${routes.map(r => r.qualified).join(', ')}.`
204 }
205
206 const lines: string[] = []
207 if (!q) lines.push(`${routes.length} routes, ${regs.length} registrations (${flavour}).`)
208 for (const r of picked.sort((a, b) => a.qualified.localeCompare(b.qualified))) {
209 const where = regs.filter(g => registers(g.target, r))
210 const head = `${r.qualified}${describeArgs(r)} [${r.kind}] declared ${r.file}:${r.line}`
211 const reg = where.length === 0
212 ? 'NOT REGISTERED with any nav host'
213 : where.map(g => `${g.kind}<${g.target}> at ${g.file}:${g.line}`).join('; ')
214 lines.push(q ? `${head}\n registered: ${reg}` : `- ${head}; registered: ${reg}`)
215 }
216 if (!q) {
217 const orphans = regs.filter(g => !routes.some(r => registers(g.target, r)))
218 if (orphans.length > 0) {
219 lines.push(`Registrations naming no route found here: ${orphans.map(g => `${g.kind}<${g.target}> at ${g.file}:${g.line}`).join('; ')}.`)
220 }
221 }
222 return lines.join('\n')
223}
224hooks/project.ts 329 lines1// Reading the project through $.fs: $.process is documented CLI-only, and the
2// desktop app must work, so nothing here shells out.
3
4import type { EngineInterface } from 'claude-code'
5
6import type { LensManifest, LensNavFile, LensNavIndex, LensPermission, LensStability } from '../types'
7
8import { NAV_MARKERS, parseNavFile } from './nav'
9import { parseMergerReport, permissionsOf, usesSdk } from './manifest'
10import { mergeComposables, parseComposables, parseUnstableClasses } from './stability'
11
12type FileRef = { path: string; size: number; mtimeMs: number }
13
14export type Project = {
15 root: string
16 kt: FileRef[]
17 /** src/<set>/AndroidManifest.xml files */
18 manifests: string[]
19 /** directories holding a build.gradle(.kts), with that file */
20 modules: { dir: string; buildFile: string }[]
21}
22
23const SKIP_DIRS = new Set(['build', 'out', 'node_modules', 'Pods', 'DerivedData', 'captures', 'buildSrc-cache'])
24const MAX_DIRS = 20_000
25const MAX_KT = 8_000
26const MAX_KT_BYTES = 1_000_000
27const BATCH = 16
28
29export function join(dir: string, name: string): string {
30 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
31}
32
33export function relative(root: string, path: string): string {
34 const base = root.endsWith('/') ? root : root + '/'
35 return path.startsWith(base) ? path.slice(base.length) : path
36}
37
38export function absolute(root: string, path: string): string {
39 return path.startsWith('/') ? path : join(root, path)
40}
41
42/** The Gradle path of a module directory (`feature/scan` -> `:feature:scan`). */
43export function gradlePath(root: string, dir: string): string {
44 const rel = relative(root, dir)
45 return rel === dir && dir === root ? ':' : ':' + rel.split('/').filter(s => s.length > 0).join(':')
46}
47
48async function inBatches<T, R>(items: readonly T[], fn: (item: T) => Promise<R>): Promise<R[]> {
49 const out: R[] = []
50 for (let i = 0; i < items.length; i += BATCH) {
51 out.push(...(await Promise.all(items.slice(i, i + BATCH).map(fn))))
52 }
53 return out
54}
55
56/** Walks the project once: Kotlin files, source manifests and modules. */
57export async function scanProject($: EngineInterface, root: string): Promise<Project> {
58 const project: Project = { root, kt: [], manifests: [], modules: [] }
59 let frontier = [root]
60 let seen = 0
61 while (frontier.length > 0 && seen < MAX_DIRS) {
62 const next: string[] = []
63 const listings = await inBatches(frontier, async dir => {
64 try {
65 return { dir, entries: await $.fs.list(dir) }
66 } catch {
67 return { dir, entries: [] }
68 }
69 })
70 for (const { dir, entries } of listings) {
71 seen++
72 const parent = dir.slice(dir.lastIndexOf('/') + 1)
73 for (const entry of entries) {
74 const path = join(dir, entry.name)
75 if (entry.kind === 'dir') {
76 if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
77 // Test source sets hold no routes worth indexing.
78 if (parent === 'src' && /(^test$|Test$)/.test(entry.name)) continue
79 next.push(path)
80 } else if (entry.kind === 'file') {
81 if (entry.name.endsWith('.kt') && project.kt.length < MAX_KT) {
82 project.kt.push({ path, size: entry.size, mtimeMs: entry.mtimeMs })
83 } else if (entry.name === 'AndroidManifest.xml' && /\/src\/[^/]+$/.test(dir)) {
84 project.manifests.push(path)
85 } else if (entry.name === 'build.gradle.kts' || entry.name === 'build.gradle') {
86 project.modules.push({ dir, buildFile: path })
87 }
88 }
89 }
90 }
91 frontier = next
92 }
93 return project
94}
95
96async function readOr($: EngineInterface, path: string, fallback: string): Promise<string> {
97 try {
98 const text = await $.fs.read(path)
99 return typeof text === 'string' ? text : fallback
100 } catch {
101 return fallback
102 }
103}
104
105/** Parses one file into the index's shape, or null when it holds nothing of nav. */
106async function navFile($: EngineInterface, root: string, path: string): Promise<LensNavFile | null> {
107 const src = await readOr($, path, '')
108 if (!NAV_MARKERS.test(src)) return null
109 const parsed = parseNavFile(relative(root, path), src)
110 return parsed.routes.length > 0 || parsed.registrations.length > 0 ? parsed : null
111}
112
113/** Builds the route index from the walk's Kotlin files. */
114export async function indexNav($: EngineInterface, project: Project, now: number): Promise<LensNavIndex> {
115 const files: Record<string, LensNavFile> = {}
116 const small = project.kt.filter(f => f.size <= MAX_KT_BYTES)
117 const parsed = await inBatches(small, async f => [f.path, await navFile($, project.root, f.path)] as const)
118 for (const [path, file] of parsed) if (file !== null) files[relative(project.root, path)] = file
119 return { files, indexedAt: now }
120}
121
122/** Re-reads the given files into a copy of the index. */
123export async function refreshNav($: EngineInterface, root: string, index: LensNavIndex, paths: readonly string[], now: number): Promise<LensNavIndex> {
124 const files = { ...index.files }
125 for (const path of paths) {
126 const file = await navFile($, root, absolute(root, path))
127 const key = relative(root, absolute(root, path))
128 if (file === null) delete files[key]
129 else files[key] = file
130 }
131 return { files, indexedAt: now }
132}
133
134// ---- Manifest ------------------------------------------------------------
135
136const APP_PLUGIN = /com\.android\.application|android[.\-_]?application/i
137
138async function newestMergedManifest($: EngineInterface, moduleDir: string): Promise<{ path: string; mtimeMs: number; variant: string } | undefined> {
139 const intermediates = join(moduleDir, 'build/intermediates')
140 let roots: string[]
141 try {
142 roots = (await $.fs.list(intermediates)).filter(e => e.kind === 'dir' && /^merged_manifests?$/.test(e.name)).map(e => join(intermediates, e.name))
143 } catch {
144 return undefined
145 }
146 let best: { path: string; mtimeMs: number; variant: string } | undefined
147 const consider = async (path: string, variant: string) => {
148 try {
149 const st = await $.fs.stat(path)
150 if (st.kind === 'file' && (best === undefined || st.mtimeMs > best.mtimeMs)) best = { path, mtimeMs: st.mtimeMs, variant }
151 } catch {
152 // not there in this AGP version's layout
153 }
154 }
155 for (const r of roots) {
156 // AGP 4-7: merged_manifests/<v>/AndroidManifest.xml; 8.0-8.2: merged_manifest/<v>/AndroidManifest.xml;
157 // 8.3+: merged_manifest/<v>/process<V>MainManifest/AndroidManifest.xml.
158 const variants = await $.fs.list(r).catch(() => [])
159 for (const v of variants.filter(e => e.kind === 'dir')) {
160 const vdir = join(r, v.name)
161 const inside = await $.fs.list(vdir).catch(() => [])
162 for (const e of inside) {
163 if (e.kind === 'file' && e.name === 'AndroidManifest.xml') await consider(join(vdir, e.name), v.name)
164 if (e.kind === 'dir') await consider(join(join(vdir, e.name), 'AndroidManifest.xml'), v.name)
165 }
166 }
167 }
168 return best
169}
170
171async function mergerAttribution($: EngineInterface, moduleDir: string, variant: string): Promise<Record<string, string[]>> {
172 const logs = join(moduleDir, 'build/outputs/logs')
173 const want = variant.toLowerCase().replace(/[^a-z0-9]/g, '')
174 const entries = await $.fs.list(logs).catch(() => [])
175 const report = entries
176 .filter(e => e.kind === 'file' && e.name.startsWith('manifest-merger-') && e.name.toLowerCase().replace(/[^a-z0-9]/g, '').includes(want))
177 .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
178 if (report === undefined) return {}
179 return parseMergerReport(await readOr($, join(logs, report.name), ''))
180}
181
182function sdkFromText(text: string, key: 'targetSdk' | 'minSdk'): string | undefined {
183 const m = new RegExp(`\\b(?:android[-_.]?)?${key}(?:Version)?\\w*\\s*[=(]\\s*"?(\\d{2})"?`, 'i').exec(text)
184 return m?.[1]
185}
186
187function libraryName(origin: string): string {
188 // A path under the Gradle caches names the artifact's folder; a coordinate is kept.
189 const cache = /transforms[^/]*\/[^/]+\/transformed\/([^/]+)\//.exec(origin)
190 return cache?.[1] ?? origin
191}
192
193/** The manifest facts for the system prompt, or null for a project with no manifest. */
194export async function gatherManifest($: EngineInterface, project: Project): Promise<LensManifest | null> {
195 if (project.manifests.length === 0) return null
196 const { root } = project
197 const moduleOf = (path: string) =>
198 project.modules
199 .filter(m => path.startsWith(m.dir + '/'))
200 .sort((a, b) => b.dir.length - a.dir.length)[0]
201
202 const buildTexts = new Map<string, string>()
203 for (const m of project.modules) buildTexts.set(m.dir, await readOr($, m.buildFile, ''))
204 let appDirs = project.modules.filter(m => m.dir !== root && APP_PLUGIN.test(buildTexts.get(m.dir) ?? '')).map(m => m.dir)
205 if (appDirs.length === 0) appDirs = project.modules.filter(m => /\/(app|androidApp)$/.test(m.dir)).map(m => m.dir)
206
207 const facts: LensManifest = { app: [], modules: [], libraries: [], removed: [] }
208 const projectNames = new Set<string>()
209 for (const path of project.manifests) {
210 const mod = moduleOf(path)
211 const modDir = mod?.dir ?? root
212 const set = /\/src\/([^/]+)\/AndroidManifest\.xml$/.exec(path)?.[1] ?? 'main'
213 const { declared, removed } = permissionsOf(await readOr($, path, ''))
214 const isApp = appDirs.includes(modDir)
215 for (const name of declared) {
216 projectNames.add(name)
217 const origin = `${gradlePath(root, modDir)}${set === 'main' ? '' : ` src/${set}`}`
218 const list = isApp ? facts.app : facts.modules
219 if (!list.some(p => p.name === name && p.origin === origin)) list.push({ name, origin })
220 }
221 for (const name of removed) if (!facts.removed.includes(name)) facts.removed.push(name)
222 }
223
224 // The merged manifest: the real SDK levels and what libraries bring in.
225 let merged: { path: string; mtimeMs: number; variant: string; dir: string } | undefined
226 for (const dir of appDirs) {
227 const found = await newestMergedManifest($, dir)
228 if (found !== undefined && (merged === undefined || found.mtimeMs > merged.mtimeMs)) merged = { ...found, dir }
229 }
230 if (merged !== undefined) {
231 const xml = await readOr($, merged.path, '')
232 const sdk = usesSdk(xml)
233 const where = `merged manifest, ${gradlePath(root, merged.dir)} ${merged.variant}`
234 if (sdk.targetSdk !== undefined) {
235 facts.targetSdk = sdk.targetSdk
236 facts.targetSdkFrom = where
237 }
238 if (sdk.minSdk !== undefined) {
239 facts.minSdk = sdk.minSdk
240 facts.minSdkFrom = where
241 }
242 const attribution = await mergerAttribution($, merged.dir, merged.variant)
243 const libraries: LensPermission[] = []
244 for (const name of permissionsOf(xml).declared) {
245 if (projectNames.has(name)) continue
246 const from = (attribution[name] ?? []).filter(o => !o.startsWith(root)).map(libraryName)
247 libraries.push({ name, origin: from.length > 0 ? from.join(', ') : 'a library' })
248 }
249 facts.libraries = libraries
250 facts.merged = { path: relative(root, merged.path), mtimeMs: merged.mtimeMs }
251 }
252
253 // Fall back to literals in the build files, the version catalog, then build-logic.
254 if (facts.targetSdk === undefined || facts.minSdk === undefined) {
255 const sources: [string, string][] = []
256 for (const m of project.modules.filter(x => appDirs.includes(x.dir))) {
257 sources.push([relative(root, m.buildFile), buildTexts.get(m.dir) ?? ''])
258 }
259 const catalog = join(root, 'gradle/libs.versions.toml')
260 sources.push(['gradle/libs.versions.toml', await readOr($, catalog, '')])
261 for (const f of project.kt.filter(k => /\/build-logic\//.test(k.path)).slice(0, 40)) {
262 sources.push([relative(root, f.path), await readOr($, f.path, '')])
263 }
264 for (const [from, text] of sources) {
265 if (facts.targetSdk === undefined) {
266 const v = sdkFromText(text, 'targetSdk')
267 if (v !== undefined) {
268 facts.targetSdk = v
269 facts.targetSdkFrom = from
270 }
271 }
272 if (facts.minSdk === undefined) {
273 const v = sdkFromText(text, 'minSdk')
274 if (v !== undefined) {
275 facts.minSdk = v
276 facts.minSdkFrom = from
277 }
278 }
279 }
280 }
281 return facts
282}
283
284// ---- Compose compiler reports -------------------------------------------
285
286/** Where the /stability init script writes its module index. */
287export function reportIndexPath(root: string): string {
288 return join(root, 'build/android-project-lens/compose-modules.txt')
289}
290
291const MAX_REPORT_BYTES = 4 * 1024 * 1024
292
293/**
294 * Reads the Compose compiler reports the init script's index names, parsing
295 * only files changed since their last parse and merging them into the table.
296 */
297export async function loadStability($: EngineInterface, root: string, table: LensStability): Promise<{ table: LensStability; reportFiles: number }> {
298 const indexPath = reportIndexPath(root)
299 let indexStat
300 try {
301 indexStat = await $.fs.stat(indexPath)
302 } catch {
303 return { table, reportFiles: 0 }
304 }
305 const lines = (await readOr($, indexPath, '')).split('\n').map(l => l.trim()).filter(l => l.includes('='))
306 let next: LensStability = { ...table, parsed: { ...table.parsed }, unstableClasses: { ...table.unstableClasses }, indexMtime: indexStat.mtimeMs }
307 let reportFiles = 0
308 for (const line of lines) {
309 const eq = line.indexOf('=')
310 const module = line.slice(0, eq)
311 const dir = line.slice(eq + 1)
312 const entries = await $.fs.list(dir).catch(() => [])
313 for (const e of entries) {
314 if (e.kind !== 'file' || e.size > MAX_REPORT_BYTES) continue
315 const isComposables = e.name.endsWith('-composables.txt')
316 const isClasses = e.name.endsWith('-classes.txt')
317 if (!isComposables && !isClasses) continue
318 reportFiles++
319 const path = join(dir, e.name)
320 if (next.parsed[path] === e.mtimeMs) continue
321 const text = await readOr($, path, '')
322 if (isComposables) next = mergeComposables(next, parseComposables(text, module))
323 else Object.assign(next.unstableClasses, parseUnstableClasses(text))
324 next.parsed[path] = e.mtimeMs
325 }
326 }
327 return { table: next, reportFiles }
328}
329hooks/stability.ts 121 lines1// Compose compiler reports: *-composables.txt and *-classes.txt.
2//
3// Strong skipping is the default from Compose compiler 2.0.20, so "skippable"
4// says nearly nothing: only the unstable-parameter signal is kept.
5
6import type { LensComposable, LensStability } from '../types'
7
8export const EMPTY_STABILITY: LensStability = { composables: {}, unstableClasses: {}, parsed: {}, indexMtime: 0 }
9
10const FUN_HEAD = /\bfun\s+(?:<[^>]*>\s*)?(?:[\w.<>?, ]+\.)?([A-Za-z_]\w*)\s*\(/
11const PARAM = /^\s*(unstable|stable|runtime|@static|@dynamic)?\s*([A-Za-z_$][\w$]*)\s*:\s*(.+?)(?:\s*=\s*.*)?$/
12
13/**
14 * Parses a `<module>_<variant>-composables.txt` report: every composable it
15 * lists, with its unstable parameters (empty when none are).
16 */
17export function parseComposables(text: string, module: string): LensComposable[] {
18 const found: LensComposable[] = []
19 let current: LensComposable | null = null
20 for (const line of text.split('\n')) {
21 const head = FUN_HEAD.exec(line)
22 if (head !== null && !/^\s+(unstable|stable|runtime)\s/.test(line)) {
23 current = { name: head[1] as string, module, unstable: [] }
24 found.push(current)
25 if (/\)\s*$/.test(line)) current = null
26 continue
27 }
28 if (current === null) continue
29 if (/^\s*\)\s*$/.test(line)) {
30 current = null
31 continue
32 }
33 const p = PARAM.exec(line)
34 if (p !== null && p[1] === 'unstable') current.unstable.push({ name: p[2] as string, type: (p[3] as string).trim() })
35 }
36 return found
37}
38
39/**
40 * Parses a `<module>_<variant>-classes.txt` report: each unstable class and
41 * the members that make it so.
42 */
43export function parseUnstableClasses(text: string): Record<string, string> {
44 const found: Record<string, string> = {}
45 let current: string | null = null
46 let members: string[] = []
47 for (const line of text.split('\n')) {
48 const head = /^\s*(unstable|stable|runtime)\s+class\s+([A-Za-z_][\w.]*)/.exec(line)
49 if (head !== null) {
50 current = head[1] === 'unstable' ? (head[2] as string) : null
51 members = []
52 continue
53 }
54 if (current === null) continue
55 const member = /^\s*unstable\s+(val|var)\s+(\w+)\s*:\s*(.+)$/.exec(line)
56 if (member !== null) members.push(`${member[1]} ${member[2]}: ${(member[3] as string).trim()}`)
57 if (/^\s*}\s*$/.test(line)) {
58 found[current] = members.length > 0 ? `unstable members ${members.join(', ')}` : 'inferred unstable'
59 current = null
60 }
61 }
62 return found
63}
64
65/** Merges one composables report into the table: composables it lists are replaced, others kept. */
66export function mergeComposables(table: LensStability, parsed: LensComposable[]): LensStability {
67 const composables = { ...table.composables }
68 for (const c of parsed) {
69 if (c.unstable.length > 0) composables[c.name] = c
70 else delete composables[c.name]
71 }
72 return { ...table, composables }
73}
74
75/** Composables and classes the edited Kotlin text defines that the reports call unstable. */
76export function factsForEdit(table: LensStability, text: string): string[] {
77 const facts: string[] = []
78 const seen = new Set<string>()
79 for (const m of text.matchAll(new RegExp(FUN_HEAD.source, 'g'))) {
80 const name = m[1] as string
81 const c = table.composables[name]
82 if (c === undefined || seen.has(name)) continue
83 seen.add(name)
84 facts.push(
85 `${name} (${c.module}) has unstable parameters: ${c.unstable.map(u => `${u.name}: ${u.type}`).join(', ')}.`,
86 )
87 }
88 for (const m of text.matchAll(/\bclass\s+([A-Za-z_]\w*)/g)) {
89 const name = m[1] as string
90 const hit = Object.entries(table.unstableClasses).find(([k]) => k === name || k.endsWith('.' + name))
91 if (hit === undefined || seen.has(name)) continue
92 seen.add(name)
93 facts.push(`class ${hit[0]} is unstable (${hit[1]}).`)
94 }
95 if (facts.length === 0) return []
96 const shown = facts.slice(0, 4)
97 return [
98 `Compose compiler report (android-project-lens /stability): ${shown.join(' ')} ` +
99 'With strong skipping these still skip when the same instances are passed again; they recompose only when a new instance arrives each time, such as a list or lambda-holding object built during composition.',
100 ]
101}
102
103/** The /stability summary of the table. */
104export function describeStability(table: LensStability): string {
105 const composables = Object.values(table.composables).sort((a, b) => b.unstable.length - a.unstable.length)
106 const classes = Object.entries(table.unstableClasses)
107 if (composables.length === 0 && classes.length === 0) {
108 return 'The Compose compiler reports list no composable with unstable parameters.'
109 }
110 const lines = [`${composables.length} composables have unstable parameters; ${classes.length} classes are unstable.`]
111 for (const c of composables.slice(0, 15)) {
112 lines.push(`- ${c.name} (${c.module}): ${c.unstable.map(u => `${u.name}: ${u.type}`).join(', ')}`)
113 }
114 if (composables.length > 15) lines.push(`- and ${composables.length - 15} more`)
115 if (classes.length > 0) {
116 lines.push(`Unstable classes: ${classes.slice(0, 10).map(([k, why]) => `${k} (${why})`).join('; ')}${classes.length > 10 ? `; and ${classes.length - 10} more` : ''}.`)
117 }
118 lines.push('Strong skipping is on by default, so these matter only where a new instance is created on each recomposition.')
119 return lines.join('\n')
120}
121hooks/kotlin.ts 106 lines1// Small helpers for reading Kotlin source without a parser.
2
3/**
4 * Blanks comments and string contents with spaces, keeping every newline and
5 * offset, so structural regexes do not match inside them and line numbers and
6 * slices of the original still line up.
7 */
8export function blankCommentsAndStrings(src: string): string {
9 const out = src.split('')
10 const blank = (from: number, to: number) => {
11 for (let i = from; i < to && i < out.length; i++) {
12 if (out[i] !== '\n') out[i] = ' '
13 }
14 }
15 let i = 0
16 while (i < src.length) {
17 const c = src[i]
18 const d = src[i + 1]
19 if (c === '/' && d === '/') {
20 const end = src.indexOf('\n', i)
21 const stop = end === -1 ? src.length : end
22 blank(i, stop)
23 i = stop
24 } else if (c === '/' && d === '*') {
25 const end = src.indexOf('*/', i + 2)
26 const stop = end === -1 ? src.length : end + 2
27 blank(i, stop)
28 i = stop
29 } else if (c === '"' && src.startsWith('"""', i)) {
30 const end = src.indexOf('"""', i + 3)
31 const stop = end === -1 ? src.length : end + 3
32 blank(i + 3, stop - 3)
33 i = stop
34 } else if (c === '"') {
35 let j = i + 1
36 while (j < src.length && src[j] !== '"' && src[j] !== '\n') j += src[j] === '\\' ? 2 : 1
37 blank(i + 1, j)
38 i = j + 1
39 } else if (c === "'") {
40 let j = i + 1
41 while (j < src.length && src[j] !== "'" && src[j] !== '\n' && j - i < 12) j += src[j] === '\\' ? 2 : 1
42 blank(i + 1, j)
43 i = j + 1
44 } else {
45 i++
46 }
47 }
48 return out.join('')
49}
50
51/** The offsets at which each line starts, for offset-to-line lookups. */
52export function lineStarts(src: string): number[] {
53 const starts = [0]
54 for (let i = 0; i < src.length; i++) if (src[i] === '\n') starts.push(i + 1)
55 return starts
56}
57
58/** The 1-based line holding `offset`. */
59export function lineAt(starts: readonly number[], offset: number): number {
60 let lo = 0
61 let hi = starts.length - 1
62 while (lo < hi) {
63 const mid = (lo + hi + 1) >> 1
64 if ((starts[mid] ?? 0) <= offset) lo = mid
65 else hi = mid - 1
66 }
67 return lo + 1
68}
69
70/** The index just past the bracket that closes the one at `open`, or -1. */
71export function closeOf(text: string, open: number): number {
72 const pairs: Record<string, string> = { '(': ')', '<': '>', '[': ']', '{': '}' }
73 const stack: string[] = []
74 for (let i = open; i < text.length; i++) {
75 const c = text[i] as string
76 if (c in pairs) {
77 // `->` and comparison `<` inside default values are not brackets.
78 if (c === '<' && stack.length > 0 && stack[stack.length - 1] !== '>' && text[i + 1] === '=') continue
79 stack.push(pairs[c] as string)
80 } else if (c === stack[stack.length - 1]) {
81 if (c === '>' && text[i - 1] === '-') continue
82 stack.pop()
83 if (stack.length === 0) return i + 1
84 }
85 }
86 return -1
87}
88
89/** Splits `text` at commas that sit outside every bracket. */
90export function splitTopLevel(text: string): string[] {
91 const parts: string[] = []
92 let depth = 0
93 let from = 0
94 for (let i = 0; i < text.length; i++) {
95 const c = text[i]
96 if (c === '(' || c === '[' || c === '{' || c === '<') depth++
97 else if (c === ')' || c === ']' || c === '}' || (c === '>' && text[i - 1] !== '-')) depth = Math.max(0, depth - 1)
98 else if (c === ',' && depth === 0) {
99 parts.push(text.slice(from, i))
100 from = i + 1
101 }
102 }
103 parts.push(text.slice(from))
104 return parts.map(p => p.trim()).filter(p => p.length > 0)
105}
106types/index.d.ts 83 lines1/** One argument of a route's primary constructor. */
2export type LensRouteArg = { name: string; type: string; default?: string }
3
4/** A `@Serializable` route class or object found in the sources. */
5export type LensRoute = {
6 /** Simple name (`Detail`). */
7 name: string
8 /** Name qualified by its enclosing declarations (`Route.Detail`). */
9 qualified: string
10 kind: 'object' | 'class'
11 args: LensRouteArg[]
12 file: string
13 line: number
14}
15
16/** A place that registers a route with a nav host. */
17export type LensRegistration = {
18 /** The type argument as written (`Route.Detail`). */
19 target: string
20 /** `composable`, `dialog`, `navigation` (Navigation 2) or `entry` (Navigation 3). */
21 kind: string
22 file: string
23 line: number
24}
25
26/** Routes and registrations of one source file. */
27export type LensNavFile = { routes: LensRoute[]; registrations: LensRegistration[] }
28
29/** The whole route index, by project-relative path. */
30export type LensNavIndex = { files: Record<string, LensNavFile>; indexedAt: number }
31
32/** One composable from a Compose compiler report that has unstable parameters. */
33export type LensComposable = {
34 name: string
35 module: string
36 unstable: { name: string; type: string }[]
37}
38
39/** What the Compose compiler reports said, merged across runs. */
40export type LensStability = {
41 composables: Record<string, LensComposable>
42 unstableClasses: Record<string, string>
43 /** report file path -> mtimeMs at its last parse */
44 parsed: Record<string, number>
45 /** mtimeMs of the module index the init script wrote, at the last read */
46 indexMtime: number
47}
48
49/** One permission and where it comes from. */
50export type LensPermission = { name: string; origin: string }
51
52/** Manifest facts for the system prompt. */
53export type LensManifest = {
54 targetSdk?: string
55 targetSdkFrom?: string
56 minSdk?: string
57 minSdkFrom?: string
58 /** Declared in an application module's own source manifests. */
59 app: LensPermission[]
60 /** Declared in other project modules' source manifests. */
61 modules: LensPermission[]
62 /** Present only in the merged manifest: brought in by libraries. */
63 libraries: LensPermission[]
64 /** Removed with tools:node="remove" in a source manifest. */
65 removed: string[]
66 merged?: { path: string; mtimeMs: number }
67}
68
69declare module 'claude-code' {
70 interface PluginState {
71 'android-project-lens': {
72 root: string
73 nav: LensNavIndex
74 /** .kt files edited since the index last read them */
75 dirtyKt: string[]
76 /** unregistered routes already reported, so a turn reports only new ones */
77 reportedUnregistered: string[]
78 stability: LensStability
79 manifest: LensManifest | null
80 }
81 }
82}
83