A plugin runs code inside Code on the Go. It is an Android application module that packages
into a .cgp and installs through the Plugin Manager.
This file holds everything specific to building one. Repository-wide rules — the branch
policy, the addon check, verification on a device — are in the root CLAUDE.md.
If you want a project template, a snippet or a code action, you are in the wrong directory.
See the table in CLAUDE.md.
Build one plugin:
cd plugins/Voice-Alerts # or any addon folder
../../gradlew assemblePlugin # release .cgp -> build/plugin/<pluginName>.cgp
../../gradlew assemblePluginDebug # debug variantBuild every plugin, or the ones you name:
./scripts/build-plugins.sh # every plugin, against the committed libs/
./scripts/build-plugins.sh Voice-Alerts # only these (directory name or slug)
./scripts/build-plugins.sh --ref <branch-or-tag> # refresh libs/ from CodeOnTheGo first
./scripts/build-plugins.sh --local ../CodeOnTheGo # refresh libs/ from an existing checkoutbuild-plugins.sh lists plugins with addons discover --kind plugin, which finds every
build.gradle.kts that applies com.itsaky.androidide.plugins.build. With --ref or
--local it first calls scripts/update-libs.sh, which clones CoGo into .cache/CodeOnTheGo/
on first run, rebuilds both jars and copies them into libs/. update-libs.sh builds no
addon by itself. Templates have their own script, scripts/build-templates.sh, and never need
the jars.
local.properties must contain sdk.dir=.... The committed local.properties at the repo
root is harmless leftover; each plugin needs its own.
libs/ holds five jars. Every plugin depends on at least these two:
plugin-api.jar— the IDE-side API surface (IPlugin,PluginContext,BuildStatusListener,IdeBuildService, etc.). Each plugin uses it ascompileOnly(provided by the IDE at runtime) AND asbuildscript classpathso the Gradle plugin can resolve symbols at configuration time.gradle-plugin.jar— the Gradle plugin with idcom.itsaky.androidide.plugins.build, applied by every plugin. It's the output of CoGo'splugin-api/plugin-builder/module (separate from CoGo'sgradle-plugin/module, which is unrelated despite the name). It packages the compiled Android library into a.cgp.
There is also one shared Gradle wrapper at the repo root (gradlew + gradle/wrapper/).
New plugins should use it — build them with cd plugins/<Addon> && ../../gradlew assemblePlugin rather than bundling a per-plugin gradlew/gradle/wrapper/ copy. (The five
AI-* addons follow this; most older plugins still carry their own local wrapper and can be
migrated opportunistically.)
An addon under plugins/ references the shared jars as ../../libs/*.jar. Always use the
repo-root libs/ jars and the repo-root Gradle wrapper — never bundle per-plugin copies. A
plugin that ships its own libs/plugin-api.jar / libs/gradle-plugin.jar (e.g. copied from
another plugin) can drift out of sync with the rest of the repo; point build.gradle.kts
(compileOnly) and settings.gradle.kts (buildscript classpath) at ../../libs/*.jar and
delete any local libs/. The root plugin-api.jar already carries the full API surface
(including IdeTemplateService/CgtTemplateBuilder), so newer sub-APIs do not justify a
local copy. A plugin folder is not standalone in isolation — copy the root libs/ along
if you move one elsewhere. When CoGo's API changes, refresh via the script above or the
Update libs from CodeOnTheGo GitHub Action (which commits the refreshed jars and cuts a
release). Publishing addons is a separate workflow, Publish addons, which uploads to
Cloudflare R2. It does not build against the committed libs/: it overwrites both jars
with the assets of CoGo's
plugin-api-latest
release (cut by hand with CoGo's Release plugin-api workflow, from CoGo main) after
checking them against its checksums.txt. So a plugin that adopts a new API publishes once
that API is in a plugin-api-latest release, whether or not libs/ has been refreshed. PR CI
and local builds still use the committed libs/, which Update libs fills from CoGo
stage, so an API that is on stage but not yet released passes PR CI and fails Publish
addons.
A plugin that stores a credential encrypts it with
com.itsaky.androidide.plugins.security.KeystoreSecretStore from plugin-api.jar
(26.36+ — set plugin.min_ide_version accordingly). It is compileOnly like the rest of
the API, so there is one implementation in the IDE's process rather than a copy compiled into
each .cgp. Do not re-implement AES/GCM in a plugin; three AI plugins each grew a copy that
started to diverge, which is what ADFA-5255 removed.
Construct it with this plugin's own alias (KeystoreSecretStore(ALIAS)) as a single
top-level val in a SecureApiKeyStore.kt/SecureTokenStore.kt that holds nothing but the
alias; callers use that instance directly. It takes no log tag — the store logs under its own
name — and that single-argument constructor is the only one a plugin can reach: the
two-argument form takes an internal SecretKeySource, which is not on the plugin's compile
classpath at all. ai-agent-mcp, ai-agent-gemini and ai-agent-openai are the reference
shape. Do not wrap it in an object of forwarding methods — that is just a second copy of
the store's contract to keep in step. The alias must be unique per plugin (all plugins share
the host's UID and Keystore, so a shared alias lets one plugin's invalidated-key recovery
delete another's secret) and must never change across releases.
readAndMigrate returns a four-way Stored rather than a nullable String on purpose — each
state needs different advice, and a plugin that collapses them tells a user their credential
was refused when it was never sent:
Absent— nothing was ever saved. The ordinary first run; say nothing.Value— the plaintext. Trim it at the call site if your credential format wants it;readAndMigratemigrates verbatim.Unreadable— stored, but this device's Keystore can no longer open it (a restored backup, an OEM Keystore reset). Permanent: the user has to enter it again.Unavailable— the Keystore would not answer this time. Transient: the credential is intact, so retry and never re-prompt. In particular a pane that readsUnavailablemust not dress itself as never-configured, and nothing on that screen may write over the credential it could not read — an empty field then means "not shown", not "removed".
Handle all four; collapse them only where the caller genuinely has one answer for every state, and say so in a comment.
A plugin is an Android application module (despite installing as a library) with:
build.gradle.ktsappliescom.android.applicationandcom.itsaky.androidide.plugins.build. It must not applyorg.jetbrains.kotlin.android— AGP 9 compiles Kotlin itself and refuses that plugin ("already on the classpath with an unknown version"); the Kotlin version is pinned on the buildscript classpath insettings.gradle.ktsinstead, which is where AGP 9 takes its compiler from. ConfigurespluginBuilder { pluginName = "..." }. UsescompileOnly(files("../../libs/plugin-api.jar"))— neverimplementation.settings.gradle.ktsdeclares the jars it needs on the buildscript classpath plus AGP and Kotlin.src/main/AndroidManifest.xmldeclares plugin identity as<meta-data>entries on<application>:plugin.id,plugin.name,plugin.version(resolved from${pluginVersion}),plugin.description,plugin.author,plugin.main_class,plugin.min_ide_version, and optionalplugin.permissions. Optionallyplugin.vcs_revision/plugin.build_timestamp— see Build provenance below.- Main class implements
com.itsaky.androidide.plugins.IPlugin. Lifecycle:initialize(PluginContext) → activate() → deactivate() → dispose(). Services are obtained viacontext.services.get(SomeService::class.java)(e.g.IdeBuildServicefor build hooks). AndroidContextiscontext.androidContext.
Available permission strings (declared comma-separated in plugin.permissions):
filesystem.read, filesystem.write, network.access, system.commands, ide.settings,
project.structure.
Every .cgp records the commit it was built from, so a crash report or a support question
traces back to source (ADFA-5256). The builder resolves it once per build and publishes it
three ways: two <meta-data> entries, assets/cgp-build.properties inside the archive, and
the IDE's plugin details dialog.
Manifests opt in by referencing the placeholders — the builder never injects <meta-data> on
your behalf:
<meta-data android:name="plugin.vcs_revision" android:value="${pluginVcsRevision}" />
<meta-data android:name="plugin.build_timestamp" android:value="${pluginBuildTimestamp}" />This is a hard build-time coupling to libs/gradle-plugin.jar. A manifest that references
a placeholder the builder does not define fails the manifest merger outright ("requires a
placeholder substitution but no value ... is provided"), and all plugins resolve the builder
from the single committed jar. So a manifest may only adopt these after the builder change
is merged in CoGo and the Update libs from CodeOnTheGo Action has refreshed libs/. Never
the other way round. The same coupling hits on-device builders, whose builder jar ships in
plugin-maven-repo.zip and refreshes only with a CoGo app release — a plugin referencing
these cannot be built inside an older CoGo at all. The builder change (ADFA-5394) first ships
in 26.37, so every adopting plugin's source is unbuildable on device in 26.36 and earlier,
with only the merger's placeholder error to go on. plugin.min_ide_version does not express
this: it gates installing the .cgp, not building its source.
Read the record out of a built artifact:
unzip -p <plugin>/build/plugin/<name>.cgp assets/cgp-build.propertiesrevision_source says how the revision was found, in the order the builder tries: explicit
(you set pluginBuilder { pluginVcsRevision = "..." }) → env:<VAR>
(PLUGIN_VCS_REVISION, GITHUB_SHA, CI_COMMIT_SHA, GIT_COMMIT) → git → git-dir
(reads .git directly; this is the on-device path, since CoGo ships JGit in-process and no
git binary) → none, which means revision=unknown. +dirty is appended when the plugin's
own directory has uncommitted changes; the check is scoped to that directory so a libs/
refresh elsewhere in the tree does not flag the build.
timestamp is the committer date of that revision in UTC, not the wall clock, and it lands in
the version string as well (1.0.0-release.<timestamp>), so two builds of one commit produce
a .cgp whose every entry matches by CRC and mtime (see CoGo's ADR-0012). The archives are
not byte-identical: the v2 APK Signing Block differs between runs, so compare entry CRCs
rather than a file hash. That determinism holds only where the builder could reach a git
binary; it falls back to the clock and says so with timestamp_source=wall-clock, and the
stamp it puts in the version string then changes on every build, so the artifact is not
reproducible even entry-by-entry. On device it is always the fallback — CoGo ships no git —
and under --configuration-cache the clock reading additionally freezes into the cached
configuration.
+dirty has one systemic cause worth designing against: a build-time download must land on
a gitignored path. A downloadAssets task that overwrites a git-tracked file
(ndk-installer shipped a committed placeholder ndk-cmake.tar.xz until it was untracked)
dirties the plugin directory on every build, so every artifact it ever produces records
+dirty and no build of that plugin is traceable to a clean commit. Both download plugins now
fetch onto ignored paths (plugins/NDK-Installer/.gitignore,
plugins/AI-Literacy-Course/.gitignore); keep it that way when adding a new one.
libs_revision records which CoGo commit produced the jars the plugin was compiled against.
The builder cannot see that checkout, so each build path exports PLUGIN_LIBS_REVISION first:
scripts/build-plugins.sh from the CodeOnTheGo checkout update-libs.sh just built, and Publish addons
from the target commit of the plugin-api-latest release it downloaded. Compare two
artifacts' libs_revision by prefix, not equality: .cgps published before that change took
theirs from a commit subject, and some of those carry a 9-character sha. Note that under Update libs from CodeOnTheGo the plugin's own revision
is the commit before the chore: update libs commit, because plugins are built before that
commit is created; libs_revision is what pins the pairing.
scripts/verify-provenance.sh asserts the record after each assemblePlugin: a .cgp
missing assets/cgp-build.properties, missing any required key, or disagreeing with the
exported PLUGIN_LIBS_REVISION fails the run. revision=unknown, +dirty and
timestamp_source=wall-clock warn instead — all three are legitimate off-CI (no .git, no
git binary). All three workflows call it through scripts/build-plugins.sh. The last one is the one that matters most: those are the artifacts users
install from the gallery.
Every plugin with UI implements
com.itsaky.androidide.plugins.extensions.DocumentationExtension. This wiring is fixed and
foundational — get all of it right or the tooltip renders the literal string n/a at
runtime. The build stays green and the manifest looks fine, so only device long-press
testing catches a mistake (this bit us once). All symbols are in plugin-api.jar.
- Category is
"plugin_<pluginId>"— exactly.getTooltipCategory()MUST return"plugin_"+ the fullplugin.id(e.g."plugin_org.appdevforall.projecttotemplate"). The host registers your entries under this string and derives the same string when resolving a lookup. Any other value — a short slug, a dotless/underscore form — silently mismatches →n/a. - Entries.
getTooltipEntries()returnsPluginTooltipEntry(tag, summary, detail, buttons):summary= Tier 1 (one line shown on long-press),detail= Tier 2 (HTML behind "See more"). Keep thetagin one sharedconst valused by steps 3–4. - Look tooltips up with the 3-arg overload. Call
IdeTooltipService.showTooltip(anchorView, category, tag)and passcategory = "plugin_<pluginId>"explicitly. Never use the 2-argshowTooltip(view, tag)— it resolves under a different default category and rendersn/aeven when the entry is registered correctly. Param order is(anchorView, category, tag). - Attach tags to UI. Set
tooltipTag = <that same tag>on every contributedNavigationItem/TabItem/ menu item / FAB;EditorTabIteminstead takes a literaltooltip = "..."string. A contributed element with no tooltip fails review clause 6.7. - Tier 3 (offline page). Override
getTier3DocsAssetPath()to return an assets subdir name (convention:"docs"), ship real HTML atsrc/main/assets/<dir>/index.html(white background, black text, English), and link it from an entry viaPluginTooltipButton(description, uri = "index.html", order = 0)— leavedirectPathfalse (truetargets the host's shared docs tree, not your bundle).
Debug a mismatch against the on-device store (adb root first):
sqlite3 /data/data/com.itsaky.androidide/databases/documentation.db "SELECT c.category, t.tag, substr(t.summary,1,40) FROM Tooltips t JOIN TooltipCategories c ON c.id=t.categoryId WHERE c.category LIKE 'plugin_%'". If the row is present but the tooltip still shows n/a,
the bug is the lookup (step 1 or 3), not registration. (The unused ide_tooltip_table is
a red herring — plugin entries live in Tooltips + TooltipCategories.)
Most plugins end with:
tasks.matching {
it.name.contains("checkDebugAarMetadata") ||
it.name.contains("checkReleaseAarMetadata")
}.configureEach { enabled = false }This is intentional — the application-as-library packaging trips those checks. Keep it.
Some plugins (ndk-installer, ai-literacy-course) register a downloadAssets task that
fetches large files at build time with pinned-MD5 verification. These assets are not
committed to git — each plugin gitignores its own download paths (ai-literacy-course pulls
a ~110 MB course ZIP plus pdfjs.zip; ndk-installer pulls ndk-cmake.tar.xz). Committing
one, even as a placeholder, makes every build dirty — see Build provenance above.
scripts/build-plugins.sh runs downloadAssets automatically before assemblePlugin when the
build file references it.
A bare ./gradlew assemblePlugin does NOT run downloadAssets. Both download plugins now
fail the asset merge outright when their archives are absent, rather than silently packaging a
broken .cgp (a course with no PDF viewer, an NDK-less installer) — that silent packaging is
the failure the guards exist to prevent, so keep one on any new download plugin. When building
such a plugin by hand, run ./gradlew downloadAssets and then ./gradlew assemblePlugin as
two separate invocations (or use the script, which does exactly that). Combining them in one
invocation fails: downloadAssets declares an output inside src/main/assets, which Gradle
sees as an undeclared dependency of mergeReleaseAssets.
Plugins that extract bundled assets on-device once (currently ai-literacy-course, via
CourseInstaller) gate the work behind a marker file named from a version constant
(INSTALL_VERSION → .installed-vN). If the marker for the current version exists,
extraction and any post-extraction generation (e.g. CourseShell.generate()) are skipped
entirely.
Any change to extraction OR post-extraction generation logic must bump INSTALL_VERSION.
Otherwise the change compiles and packages cleanly but has zero effect on existing installs —
they keep the stale extracted tree, and it looks like "my fix didn't work" (costing a device
round-trip). Bumping the constant forces a clean re-extract. On a device with a prior install,
confirm the marker version changed (or wipe plugin data) before concluding a fix works.
-
Copy
plugins/Random-XKCD/— it's the canonical starting template (small but complete, includes the in-IDE help HTML pattern that submissions are expected to follow). Name the new directory in MixedCase with single hyphens between words (APK-Analyzer), ASCII letters and digits only. Every other name, filename, and URL derives from it — seedocs/addon-naming-standards.md. -
Update every copied file that still names the template. Two values come from the directory name: the slug is it lowercased (
apk-analyzer), the display name is it with hyphens replaced by spaces (APK Analyzer).File Change settings.gradle.ktsrootProject.name— Gradle's own name for the build. Nothing derives from it; keep it in step with the slug anyway.build.gradle.ktspluginBuilder { pluginName }→ the slug;android { namespace, applicationId }src/main/AndroidManifest.xmlplugin.id,plugin.name→ the display name,plugin.main_classrandom-xkcd.htmlrename to <slug>.html; set<title>to the display name exactly, and make the<h1>contain itaddon.jsonsummary,description,tags,origin,license,author. The schema checks the shape, not the words, so a copied one passes every check and puts xkcd's description on your gallery card.src/main/assets/icon_day.png,icon_night.pngreplace both; both must be present src/main/kotlin/...your implementation -
Run
uv run --directory tools/addons addons --root "$PWD" checkfrom the repository root before pushing.--rootmust be absolute:--directorymoves uv intotools/addons, so--root .resolves there and finds no addons. It is the same gatecheck-toolchain.ymlruns on every pull request, it costs a second, and it names the exact file and value it wants. Treat it as the authority — do not restate its rules here, or the two copies drift. -
Add a row to the root
README.mdExamples table. -
Nothing else to wire up. Publishing is automatic:
addons discoverfinds any directory whosebuild.gradle.ktsapplies the plugin-builder, and the name, filenames, and URLs all derive from the directory name.