-
Notifications
You must be signed in to change notification settings - Fork 21
Plugin Development Guide
An interactive TOC for the guide is located on the right side of the page.
Code on the Go supports plugins that extend IDE functionality.
Error rendering macro 'toc' : null
The core interface every plugin must implement. Defines the plugin lifecycle.
interface IPlugin {
fun initialize(context: PluginContext): Boolean
fun activate(): Boolean
fun deactivate(): Boolean
fun dispose()
}
Called once when the plugin is first loaded by the PluginManager. This is the first method called on your plugin and provides the PluginContext which you should store as a class property since you'll need it throughout your plugin's lifetime.
Use this method to:
- Store the context reference
- Set up initial configuration
- Initialize any data structures
- Validate that your plugin can run in this environment
Return true if initialization succeeded and your plugin is ready to be activated. Return false if something went wrong and your plugin should not be loaded. If you return false, the plugin will not be activated and will be marked as failed to load.
class MyPlugin : IPlugin {
private lateinit var context: PluginContext
override fun initialize(context: PluginContext): Boolean {
this.context = context
context.logger.info("MyPlugin: Initializing...")
// Check if we have what we need
val projectService = context.services.get(IdeProjectService::class.java)
if (projectService == null) {
context.logger.error("MyPlugin: Required service not available")
return false
}
return true
}
}
Called when the plugin is being enabled. This happens after initialize() returns true, and may also be called later if the user re-enables a disabled plugin.
Use this method to:
- Register event listeners
- Start background services or workers
- Make your plugin's features visible and available
- Connect to external services
Return true if activation succeeded. Return false if activation failed and your plugin should remain inactive.
override fun activate(): Boolean {
context.logger.info("MyPlugin: Activating...")
// Start listening for build events
val buildService = context.services.get(IdeBuildService::class.java)
buildService?.addBuildStatusListener(myBuildListener)
return true
}
Called when the plugin is being disabled. This can happen when the user disables the plugin, or before dispose() is called during unloading.
Use this method to:
- Unregister event listeners
- Stop background services gracefully
- Hide your plugin's UI elements
- Save any unsaved state
Return true if deactivation succeeded. The plugin may be reactivated later without being disposed.
override fun deactivate(): Boolean {
context.logger.info("MyPlugin: Deactivating...")
// Stop listening for build events
val buildService = context.services.get(IdeBuildService::class.java)
buildService?.removeBuildStatusListener(myBuildListener)
return true
}
Called when the plugin is being completely unloaded from memory. After this method returns, your plugin instance will be garbage collected.
Use this method to:
- Release all resources
- Close file handles and streams
- Clear all caches
- Disconnect from any services
- Perform final cleanup
This method has no return value. Make sure to clean up everything because your plugin instance won't exist after this.
override fun dispose() {
context.logger.info("MyPlugin: Disposing...")
// Clear any cached data
myCache.clear()
// Close any open resources
myDatabase?.close()
}
Provided during initialization, this gives your plugin access to IDE services and resources.
interface PluginContext {
val androidContext: Context
val services: ServiceRegistry
val eventBus: Any
val logger: PluginLogger
val resources: ResourceManager
val pluginId: String
}
The Android application context. Use this for any Android-specific operations that require a Context, such as accessing SharedPreferences, system services, or creating intents.
val prefs = context.androidContext.getSharedPreferences("my_plugin_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("last_used", Date().toString()).apply()
The service registry where you can access IDE services. Use get() to retrieve services by their interface class.
val projectService = context.services.get(IdeProjectService::class.java)
val editorService = context.services.get(IdeEditorService::class.java)
Reference to the event bus for plugin events. This allows plugins to communicate with each other and the IDE through events.
A logger instance specific to your plugin. All log messages are automatically tagged with your plugin ID, making it easy to filter logs.
context.logger.debug("Detailed debugging info")
context.logger.info("Normal operation message")
context.logger.warn("Warning about potential issue")
context.logger.error("Error occurred", exception)
Manages access to files and resources bundled with your plugin.
val pluginDir = context.resources.getPluginDirectory()
val configFile = context.resources.getPluginFile("config/settings.json")
val imageBytes = context.resources.getPluginResource("assets/icon.png")
Your plugin's unique identifier as declared in the manifest. Useful for logging, storing preferences, or generating unique IDs.
Logging interface for plugins. All methods accept a message string, and some accept an optional Throwable for logging exceptions.
interface PluginLogger {
val pluginId: String
fun debug(message: String)
fun debug(message: String, error: Throwable)
fun info(message: String)
fun info(message: String, error: Throwable)
fun warn(message: String)
fun warn(message: String, error: Throwable)
fun error(message: String)
fun error(message: String, error: Throwable)
}
Log a debug message. Use for detailed information useful during development but not needed in production.
Log an informational message. Use for normal operational events like "Plugin started" or "Processing file X".
Log a warning message. Use for unexpected situations that aren't errors but might indicate a problem.
Log an error message. Use when something goes wrong. The version with Throwable will include the stack trace.
Registry for accessing IDE services.
interface ServiceRegistry {
fun <T> register(serviceClass: Class<T>, implementation: T)
fun <T> get(serviceClass: Class<T>): T?
fun <T> getAll(serviceClass: Class<T>): List<T>
fun unregister(serviceClass: Class<*>)
}
Retrieves a service implementation by its interface class. Returns null if the service is not registered or not available.
val projectService = context.services.get(IdeProjectService::class.java)
if (projectService != null) {
val project = projectService.getCurrentProject()
}
Retrieves all implementations of a service interface. Useful when multiple plugins might provide the same service type.
Registers your own service implementation. Other plugins can then access your service.
context.services.register(MyCustomService::class.java, MyCustomServiceImpl())
Removes a service registration. Call this in dispose() if you registered any services.
Manages access to plugin files and resources.
interface ResourceManager {
fun getPluginDirectory(): File
fun getPluginFile(path: String): File
fun getPluginResource(name: String): ByteArray?
fun openPluginAsset(path: String): InputStream?
fun openPluginResource(name: String): InputStream?
}
Returns the root directory where your plugin's files are stored. Use this as a base path for any files your plugin needs to create or access.
val pluginDir = context.resources.getPluginDirectory()
val myDataFile = File(pluginDir, "data/myfile.json")
Returns a File object for a specific path within your plugin's directory. The path is relative to the plugin directory.
val configFile = context.resources.getPluginFile("config/settings.json")
if (configFile.exists()) {
val settings = configFile.readText()
}
Reads a resource bundled with your plugin APK and returns its contents as a byte array. Returns null if the resource doesn't exist.
val iconBytes = context.resources.getPluginResource("images/icon.png")
if (iconBytes != null) {
val bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.size)
}
Data class containing information about a plugin, parsed from the manifest.
data class PluginMetadata(
val id: String,
val name: String,
val version: String,
val description: String,
val author: String,
val minIdeVersion: String,
val permissions: List<String> = emptyList(),
val dependencies: List<String> = emptyList()
)
Unique identifier for the plugin. Should use reverse domain notation like com.example.myplugin.
Human-readable display name shown to users in the plugin manager.
Plugin version string. Recommended to use semantic versioning like 1.0.0.
Brief description of what the plugin does.
Name of the plugin author or organization.
Minimum Code on the Go version required to run this plugin.
List of permission keys the plugin requires (e.g., ["filesystem.read", "filesystem.write"]).
List of other plugin IDs this plugin depends on.
Enum defining available permissions plugins can request.
enum class PluginPermission(val key: String, val description: String) {
FILESYSTEM_READ("filesystem.read", "Read files from project directory"),
FILESYSTEM_WRITE("filesystem.write", "Write files to project directory"),
NETWORK_ACCESS("network.access", "Access network resources"),
SYSTEM_COMMANDS("system.commands", "Execute system commands"),
IDE_SETTINGS("ide.settings", "Modify IDE settings"),
PROJECT_STRUCTURE("project.structure", "Modify project structure"),
NATIVE_CODE("native.code", "Execute native machine code"),
IDE_ENVIRONMENT_WRITE("ide.environment.write", "Write to IDE-managed directories such as the Android SDK, NDK, and cache")
}
Allows reading files from the project directory. Required for IdeProjectService and IdeEditorService.
Allows writing files to the project directory. Required for IdeFileService.
Allows making network requests to external APIs and services.
Allows executing system commands.
Allows modifying IDE settings and preferences.
Allows modifying the project structure (creating/deleting files and folders).
Allows writing to IDE-managed paths returned by IdeEnvironmentService. This is distinct from FILESYSTEM_WRITE, which is scoped to project directories. A plugin that installs an SDK or toolchain requests ide.environment.write and does not need filesystem.write.
Data class representing the current state of a loaded plugin.
data class PluginInfo(
val metadata: PluginMetadata,
val isEnabled: Boolean,
val isLoaded: Boolean,
val loadError: String? = null
)
The plugin's metadata is parsed from its manifest.
Whether the plugin is currently enabled by the user.
Whether the plugin was successfully loaded into memory.
If the plugin failed to load, this contains the error message. Otherwise null.
Interface for plugins that add UI elements to the IDE. Extend your plugin class with this interface to contribute menus, tabs, and navigation items.
interface UIExtension : IPlugin {
fun getMainMenuItems(): List<MenuItem> = emptyList()
fun getContextMenuItems(context: ContextMenuContext): List<MenuItem> = emptyList()
fun getEditorTabs(): List<TabItem> = emptyList()
fun getSideMenuItems(): List<NavigationItem> = emptyList()
fun getToolbarActions(): List<ToolbarAction> = emptyList()
fun getFabActions(): List<FabAction> = emptyList()
}
Returns menu items to add to the main toolbar menu (the three-dot menu in the editor). Each MenuItem defines an action the user can trigger.
override fun getMainMenuItems(): List<MenuItem> {
return listOf(
MenuItem(
id = "my_plugin_action",
title = "My Action",
isEnabled = true,
isVisible = true,
action = { performMyAction() }
)
)
}
Returns context menu items based on where the user long-pressed. The ContextMenuContext parameter tells you about the current file, selected text, and cursor position so you can provide relevant options.
override fun getContextMenuItems(context: ContextMenuContext): List<MenuItem> {
// Only show if text is selected
if (context.selectedText.isNullOrEmpty()) return emptyList()
return listOf(
MenuItem(
id = "search_web",
title = "Search '${context.selectedText}'",
action = { searchWeb(context.selectedText!!) }
)
)
}
Returns tabs for the editor's bottom sheet panel. These tabs appear alongside built-in tabs like Build Output, Logs, and Diagnostics.
override fun getEditorTabs(): List<TabItem> {
return listOf(
TabItem(
id = "my_output_tab",
title = "My Output",
fragmentFactory = { MyOutputFragment() },
order = 100
)
)
}
Returns items for the left sidebar navigation drawer. If you use this, you must also declare plugin.sidebar_items in your manifest with the count of items.
override fun getSideMenuItems(): List<NavigationItem> {
return listOf(
NavigationItem(
id = "my_tool_nav",
title = "My Tool",
icon = android.R.drawable.ic_menu_manage,
group = "tools",
order = 0,
action = { openMyTool() }
)
)
}
Returns action buttons to add to the editor toolbar (the row of buttons at the top of the editor).
override fun getToolbarActions(): List<ToolbarAction> {
return listOf(
ToolbarAction(
id = "format_code",
title = "Format",
icon = R.drawable.ic_format,
showAsAction = ShowAsAction.IF_ROOM,
action = { formatCurrentFile() }
)
)
}
Returns floating action button configurations for different screens.
override fun getFabActions(): List<FabAction> {
return listOf(
FabAction(
id = "quick_create",
screenId = "editor",
icon = R.drawable.ic_add,
contentDescription = "Quick Create",
action = { showQuickCreateDialog() }
)
)
}
Data class representing a menu item.
data class MenuItem(
val id: String,
val title: String,
val isEnabled: Boolean = true,
val isVisible: Boolean = true,
val shortcut: String? = null,
val subItems: List<MenuItem> = emptyList(),
val action: () -> Unit
)
Unique identifier for this menu item. Should be unique across all plugins.
Display text shown in the menu.
Whether the menu item can be clicked. Disabled items appear grayed out.
Whether the menu item is shown at all. Set to false to temporarily hide an item.
List of child menu items for creating nested/submenu structures.
Lambda function called when the user clicks the menu item.
Information about where a context menu was triggered.
data class ContextMenuContext(
val file: java.io.File?,
val selectedText: String?,
val cursorPosition: Int?,
val additionalData: Map<String, Any> = emptyMap()
)
The file where the context menu was triggered. Can be null if not in a file context.
The currently selected text, if any. null if nothing is selected.
The cursor position in the file. null if not applicable.
Additional context-specific data as a map of key-value pairs.
Data class representing a bottom sheet tab.
data class TabItem(
val id: String,
val title: String,
val fragmentFactory: () -> Fragment,
val isEnabled: Boolean = true,
val isVisible: Boolean = true,
val order: Int = 0
)
Unique identifier for this tab.
Display title shown on the tab.
A lambda that creates the Fragment to display when this tab is selected. This is called lazily when the tab is first opened.
Whether the tab can be selected. Disabled tabs appear but can't be clicked.
Whether the tab is shown in the tab bar.
Position order for the tab. Lower numbers appear first (further left). Built-in tabs have orders like 0, 10, 20, etc., so use values like 100+ to appear after them.
Data class representing a sidebar navigation item.
data class NavigationItem(
val id: String,
val title: String,
val icon: Int? = null,
val isEnabled: Boolean = true,
val isVisible: Boolean = true,
val group: String? = null,
val order: Int = 0,
val action: () -> Unit
)
Unique identifier for this navigation item.
Display text shown in the sidebar.
Optional drawable resource ID for an icon shown next to the title.
Whether the item can be clicked.
Whether the item is shown in the sidebar.
Optional group name to organize related items together. Items with the same group appear in a section together. Common groups include "tools", "project", etc.
Position order within the group. Lower numbers appear first (higher up).
Lambda function called when the user clicks the navigation item.
Data class representing an editor toolbar button.
data class ToolbarAction(
val id: String,
val title: String,
val icon: Int? = null,
val showAsAction: ShowAsAction = ShowAsAction.IF_ROOM,
val isEnabled: Boolean = true,
val isVisible: Boolean = true,
val order: Int = 0,
val action: () -> Unit
)
Unique identifier for this toolbar action.
Display text. Shown as tooltip or in overflow menu depending on showAsAction.
Optional drawable resource ID for the toolbar icon.
How to display the action in the toolbar. See ShowAsAction enum.
Whether the action can be triggered.
Whether the action appears in the toolbar.
Position in the toolbar. Lower numbers appear first (further left).
Lambda function called when the user clicks the button.
Enum controlling how a toolbar action is displayed.
enum class ShowAsAction {
ALWAYS,
IF_ROOM,
NEVER,
WITH_TEXT,
COLLAPSE_ACTION_VIEW
}
Always show this action as a button in the toolbar.
Show as a button if there's space, otherwise put in overflow menu.
Always put in the overflow menu, never show as a button.
Show the title text alongside the icon (if room permits).
For expandable action views (like search bars).
Data class representing a floating action button.
data class FabAction(
val id: String,
val screenId: String,
val icon: Int,
val contentDescription: String,
val isEnabled: Boolean = true,
val isVisible: Boolean = true,
val action: () -> Unit
)
Unique identifier for this FAB action.
Which screen this FAB should appear on (e.g., "editor", "main").
Drawable resource ID for the FAB icon.
Accessibility description for screen readers.
Whether the FAB can be clicked.
Whether the FAB is shown.
Lambda function called when the user clicks the FAB.
Interface for plugins that add tabs to the main editor tab bar (alongside code file tabs).
interface EditorTabExtension : IPlugin {
fun getMainEditorTabs(): List<EditorTabItem> = emptyList()
fun onEditorTabSelected(tabId: String, fragment: Fragment) {}
fun onEditorTabClosed(tabId: String) {}
fun canCloseEditorTab(tabId: String): Boolean = true
}
Returns tabs to add to the main editor tab bar. These appear alongside file tabs, allowing users to switch between your plugin's views and code files.
override fun getMainEditorTabs(): List<EditorTabItem> {
return listOf(
EditorTabItem(
id = "my_analyzer_tab",
title = "Analyzer",
icon = android.R.drawable.ic_menu_search,
fragmentFactory = { AnalyzerFragment() },
isCloseable = true,
isPersistent = false,
order = 100,
tooltip = "Open code analyzer"
)
)
}
Called when one of your plugin's tabs becomes the active/focused tab. Use this to refresh content, start animations, or perform setup when your tab comes into view.
override fun onEditorTabSelected(tabId: String, fragment: Fragment) {
context.logger.info("Tab $tabId is now active")
if (fragment is AnalyzerFragment) {
fragment.refreshAnalysis()
}
}
Called when one of your plugin's tabs is closed by the user. Use this to clean up any resources associated with that specific tab instance.
override fun onEditorTabClosed(tabId: String) {
context.logger.info("Tab $tabId was closed")
analysisResults.remove(tabId)
}
Called when the user tries to close one of your tabs. Return true to allow closing, or false to prevent it (e.g., if there are unsaved changes).
override fun canCloseEditorTab(tabId: String): Boolean {
if (hasUnsavedChanges(tabId)) {
showSavePrompt(tabId)
return false // Prevent closing until user decides
}
return true
}
Data class representing a tab in the main editor tab bar.
data class EditorTabItem(
val id: String,
val title: String,
val icon: Int? = null,
val fragmentFactory: () -> Fragment,
val isCloseable: Boolean = true,
val isPersistent: Boolean = false,
val order: Int = 0,
val isEnabled: Boolean = true,
val isVisible: Boolean = true,
val tooltip: String? = null
)
Unique identifier for this tab. Must be unique across all plugins. Used to reference this tab when selecting or closing it programmatically.
Display title shown on the tab. Keep it short since tab space is limited.
Optional drawable resource ID for an icon shown on the tab alongside the title.
Lambda that creates the Fragment to display in this tab. Called when the tab is first opened. The Fragment should handle its own content and lifecycle.
Whether the user can close this tab. Set to false for tabs that should always remain open while the plugin is active.
Whether this tab should be automatically restored when the app restarts. If true, the tab will be recreated using fragmentFactory on next launch.
Position among plugin tabs. Lower numbers appear first (further left). File tabs always come before plugin tabs regardless of order.
Whether the tab can be selected. Disabled tabs appear but can't be clicked.
Whether the tab appears in the tab bar at all.
Optional tooltip text shown when the user hovers or long-presses the tab.
Interface for plugins that provide tooltips and help documentation.
interface DocumentationExtension : IPlugin {
fun getTooltipCategory(): String
fun getTooltipEntries(): List<PluginTooltipEntry>
fun onDocumentationInstall(): Boolean = true
fun onDocumentationUninstall() {}
fun getTier3DocsAssetPath(): String? = null
}
Returns a unique category name for your plugin's tooltips. This is used to organize tooltips in the database and prevent conflicts with other plugins. Use the format plugin_<yourpluginname>.
override fun getTooltipCategory(): String {
return "plugin_codeanalyzer"
}
Returns all tooltip entries your plugin provides. These are inserted into the plugin documentation database when your plugin is installed.
override fun getTooltipEntries(): List<PluginTooltipEntry> {
return listOf(
PluginTooltipEntry(
tag = "analyzer.main",
summary = "The Code Analyzer finds issues in your code.",
detail = "It checks for common mistakes, performance issues, and best practice violations. Run it regularly to keep your code clean.",
buttons = listOf(
PluginTooltipButton("Learn More", "docs/intro.html", 0),
PluginTooltipButton("View Rules", "docs/rules.html", 1)
)
),
PluginTooltipEntry(
tag = "analyzer.results",
summary = "Analysis results show all detected issues.",
detail = "Click on any issue to navigate to that line in your code."
)
)
}
Returns the subdirectory under your plugin's src/main/assets/ that contains a Tier 3 documentation bundleHTML, CSS, JS, images, fonts, etc. Return null (the default) to skip Tier 3.
Every file under this directory is indexed into the IDE's shared documentation database under the reserved namespace plugin/<yourpluginid>/<relative-path> and served by the local help web server. Link to these files from your tooltip buttons see the uri field below.
override fun getTier3DocsAssetPath(): String = "docs"
Target for the button. Resolved at install time, so you don't need to hand-type your plugin ID:
-
Relative paths:
"index.html","docs/rules.html"are scoped into your plugin's own Tier 3 namespace (plugin/<yourpluginid>/...)
and served by the local help web server. This is the common case. -
Absolute paths: prefix with
/(e.g."/shared/some-page"); skip the plugin-ID prefix and link to a path elsewhere in the web server.
The tooltip system prefixes the final path with <http://localhost:6174/> when rendering the button.
Called when your plugin's documentation is about to be installed into the database. Return true to proceed with installation, false to skip. You might return false if you want to delay installation until certain conditions are met.
override fun onDocumentationInstall(): Boolean {
context.logger.info("Installing documentation...")
return true
}
Called when your plugin's documentation is being removed from the database. Use this to perform any cleanup if needed.
override fun onDocumentationUninstall() {
context.logger.info("Documentation removed")
}
Data class representing a single tooltip entry.
data class PluginTooltipEntry(
val tag: String,
val summary: String,
val detail: String = "",
val buttons: List<PluginTooltipButton> = emptyList()
)
Unique identifier for this tooltip within your category. Use descriptive names like feature.help or button.save. Combined with your category, this forms the full tooltip identifier.
Brief HTML content shown in the initial tooltip (level 0). Keep this to 1-2 sentences. This is what users see first when they trigger the tooltip.
Extended HTML content shown when the user clicks "See More" (level 1). Can be longer and more comprehensive. Leave empty if you don't have additional details.
List of action buttons shown in the tooltip. Each button links to documentation or performs an action.
Data class representing an action button in a tooltip.
data class PluginTooltipButton(
val description: String,
val uri: String,
val order: Int = 0
)
Display label for the button. Keep it short (2-4 words) like "View Docs" or "Learn More".
Path for the button action. This will be prefixed with <http://localhost:6174/> by the tooltip system. Use paths like plugin/myplugin/docs/feature to point to your documentation.
Position of this button relative to others. Lower numbers appear first (further left).
Provides information about the current project.
Requires: filesystem.read permission
interface IdeProjectService {
fun getCurrentProject(): IProject?
fun getAllProjects(): List<IProject>
fun getProjectByPath(path: File): IProject?
}
getCurrentProject() returns the project currently open in the editor. Returns null if no project is open.
getAllProjects() returns a list of all projects currently loaded in the IDE.
getProjectByPath(path: File) finds and returns a project by its root directory path. Returns null if no project exists at that path.
Provides read and write access to the code editor: open files, buffer contents, cursor/selection state, language identity, tab control, buffer mutations, and file-change events.
Requires: filesystem.read for read methods, filesystem.write for methods that open, save, or mutate buffers.
Every File argument is path-validated. A host-supplied PluginPathValidator is honored if present. Otherwise, files already open in the editor are implicitly allowed, plus the default workspace roots (CodeOnTheGoProjects). Missing permission or disallowed path throws SecurityException ; no silent failures.
All lines, columns, and indices are 0-based. Ranges are start-inclusive, end-exclusive.
data class CursorPosition(val line: Int, val column: Int, val index: Int)
data class SelectionRange(
val startLine: Int, val startColumn: Int,
val endLine: Int, val endColumn: Int,
)
fun interface FileChangeListener {
fun onFileChanged(file: File?)
}
interface IdeEditorService {
// File state
fun getCurrentFile(): File?
fun getOpenFiles(): List<File>
fun isFileOpen(file: File): Boolean
fun isFileModified(file: File): Boolean
fun getModifiedFiles(): List<File>
// Buffer content
fun getCurrentFileContent(): String?
fun getFileContent(file: File): String?
fun getCurrentLineText(): String?
fun getLineText(file: File, lineNumber: Int): String?
fun getLineCount(file: File): Int
// Cursor and selection
fun getCurrentSelection(): String?
fun getCurrentCursorPosition(): CursorPosition?
fun getCurrentSelectionRange(): SelectionRange?
fun getWordAtCursor(): String?
// Language identity
fun getCurrentLanguageId(): String?
fun getFileLanguageId(file: File): String?
// Tab control
fun openFile(file: File): Boolean
fun openFileAt(file: File, line: Int, column: Int): Boolean
fun saveCurrentFile(): Boolean
// Buffer edits
fun insertTextAtCursor(text: String): Boolean
fun replaceSelection(text: String): Boolean
fun appendToLine(file: File, line: Int, text: String): Boolean
fun prependToLine(file: File, line: Int, text: String): Boolean
fun replaceLine(file: File, line: Int, newText: String): Boolean
fun insertLineBefore(file: File, line: Int, text: String): Boolean
fun deleteLine(file: File, line: Int): Boolean
fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean
// Events
fun addFileChangeListener(listener: FileChangeListener)
fun removeFileChangeListener(listener: FileChangeListener)
}
getCurrentFile() returns the file in the active tab. When the foreground tab is a plugin tab, it falls back to the last active real file as long as it is still open. Returns null if nothing is open.
getOpenFiles() returns all files in editor tabs, filtered by path validation.
isFileOpen(file) returns whether the file has an open tab.
isFileModified(file) returns true if the open file has unsaved buffer changes.
getModifiedFiles() returns every open file with unsaved changes, filtered by path validation.
getCurrentFileContent() returns the full live buffer of the active editor, including unsaved edits. Returns null if no file is active.
getFileContent(file) returns the live buffer of any open file, or null if it is not open. Does not read from disk; use IdeFileService.readFile for that.
getCurrentLineText() returns the text of the line under the cursor.
getLineText(file, lineNumber) returns a specific 0-based line from an open file, or null if out of range.
getLineCount(file) returns the line count of an open file, or 0 if not open.
getCurrentSelection() returns the selected text, or null if nothing is selected.
getCurrentCursorPosition() returns the cursor as a CursorPosition with line, column, and absolute index.
getCurrentSelectionRange() returns the selection as a SelectionRange, or null if nothing is selected.
getWordAtCursor() returns the word under the cursor. Word characters are [A-Za-z0-9_]. Returns null if the cursor is not inside a word.
getCurrentLanguageId() and getFileLanguageId(file) return a lowercase language tag derived from the file extension. Known tags: kotlin, java, xml, json, groovy, markdown, yaml, properties, shell, c, cpp, python, javascript, typescript, html, css. Unknown extensions fall through to the raw extension; files with no extension return null.
openFile(file) opens the file in a new tab if not already open. Returns true as long as an editor activity is attached; the open itself is asynchronous.
openFileAt(file, line, column) opens the file and moves the cursor to the given 0-based position.
saveCurrentFile() saves the active tab. Returns false if no real file is in the foreground.
insertTextAtCursor(text) inserts at the cursor, replacing any selection.
replaceSelection(text) replaces the current selection. Returns false if nothing is selected.
appendToLine(file, line, text) inserts at the end of the given line.
prependToLine(file, line, text) inserts at column 0 of the given line.
replaceLine(file, line, newText) replaces the content of the line without touching its trailing newline.
insertLineBefore(file, line, text) inserts a new line before the given line. If the text does not end in \\n, one is appended.
deleteLine(file, line) removes the line and its trailing newline. Handles the last-line and only-line cases cleanly.
replaceRange(file, range, newText) replaces any SelectionRange with new text. Use this when the line-level helpers are not enough.
addFileChangeListener(listener) registers a FileChangeListener notified when the user switches tabs. The file argument is null when the active tab has no backing file (e.g. a plugin tab). Listeners registered before the editor activity is up are buffered and replayed when it attaches, so registration is safe during early boot.
removeFileChangeListener(listener) unregisters a previously registered listener. Always remove in deactivate() or dispose().
Write methods marshal to the main thread internally and run inside a single batched edit, so each call is one undo step. The calling thread blocks until the edit completes. The service holds a WeakReference to the editor activity; after the activity is destroyed, methods return null or false instead of throwing.
Provides file read/write operations.
Requires: filesystem.write permission
interface IdeFileService {
fun readFile(file: File): String?
fun writeFile(file: File, content: String): Boolean
fun appendToFile(file: File, content: String): Boolean
fun insertAfterPattern(file: File, pattern: String, content: String): Boolean
fun replaceInFile(file: File, oldText: String, newText: String): Boolean
}
readFile(file: File) reads and returns the entire file content as a string. Returns null if the file can't be read.
writeFile(file: File, content: String) completely replaces the file content. Returns true if successful.
appendToFile(file: File, content: String) adds content to the end of the file. Returns true if successful.
insertAfterPattern(file: File, pattern: String, content: String) finds the first occurrence of the pattern and inserts content immediately after it. Returns true if the pattern was found and content was inserted.
replaceInFile(file: File, oldText: String, newText: String) replaces all occurrences of oldText with newText. Returns true if at least one replacement was made.
Resolves IDE-managed directories without the plugin hardcoding paths.
Requires: no permission to read paths; writes through IdeFileService or IdeArchiveService require ide.environment.write.
interface IdeEnvironmentService {
fun getIdeHomeDirectory(): File
fun getAndroidHomeDirectory(): File
fun getNdkDirectory(): File
fun getTmpDirectory(): File
fun getPluginDataDirectory(): File
}
getIdeHomeDirectory() returns the root of IDE-managed data. Holds caches, templates, snippets, tooling, and per-plugin data. Resolves to $HOME/.cg on a real device.
getAndroidHomeDirectory() returns the root of the Android SDK installation managed by the IDE. Equivalent to $ANDROID_HOME exported to build processes. Platforms, build-tools, and the NDK live beneath this directory.
getNdkDirectory() returns the NDK install root, equivalent to $ANDROID_HOME/ndk.
getTmpDirectory() returns scratch space for short-lived files (e.g. staging an archive before extraction). Contents may be evicted between plugin activations; do not rely on persistence.
getPluginDataDirectory() returns a per-plugin persistent data directory, auto-created on first access. Scoped to the calling plugin. Survives IDE restarts. Use for plugin state that needs to outlive enable/disable cycles. Two plugins calling this get different directories.
val env = context.services.get(IdeEnvironmentService::class.java) ?: return
val ndkTarget = File(env.getNdkDirectory(), "cmake")
val pluginState = File(env.getPluginDataDirectory(), "state.json")
Extracts bundled archives into IDE-managed or project directories. Uses commons-compress plus org.tukaani:xz centrally so plugins do not each bundle compression libs.
Requires: filesystem.write or ide.environment.write depending on destination.
interface IdeArchiveService {
fun extract(
source: InputStream,
format: ArchiveFormat,
destination: File,
onProgress: ((bytesProcessed: Long, currentEntry: String?) -> Unit)? = null
): ExtractResult
}
extract(source, format, destination, onProgress) decompresses source into destination.
For single-stream formats (ArchiveFormat.XZ, ArchiveFormat.GZIP), destination is the output file path and the decompressed stream is written there as a single file.
For multi-entry formats (ArchiveFormat.TAR, TAR_XZ, TAR_GZ, ZIP), destination is the output directory and entries are extracted into it. The directory is created if it does not exist.
onProgress is invoked roughly every 1 MiB and once at the end of each entry. bytesProcessed is the running total written so far. currentEntry is the entry name for multi-entry formats or the destination file name for single-stream formats.
The caller owns source and must close it. Use stream.use { ... }.
extract is synchronous and CPU-bound for large archives. Call from a background dispatcher. Cancellation cooperates with coroutine cancellation: extract checks Thread.interrupted() between buffer reads and entries, and returns ExtractResult.Failure(InterruptedException(...)) on interrupt.
Returned ExtractResult is a sealed hierarchy. Always handle both arms.
val result = context.resources.openPluginAsset("ndk-cmake.tar.xz")!!.use { stream ->
archive.extract(
source = stream,
format = ArchiveFormat.TAR_XZ,
destination = File(env.getNdkDirectory(), "cmake")
) { bytes, entry ->
context.logger.debug("extracted ${bytes / 1_048_576} MiB (${entry.orEmpty()})")
}
}
when (result) {
is ExtractResult.Success -> context.logger.info("${result.filesExtracted} files, ${result.bytesWritten / 1_048_576} MiB")
is ExtractResult.Failure -> context.logger.error("extract failed", result.error)
}
extract applies three protections for multi-entry formats:
- Zip slip: every entry's canonical resolved path must be a descendant of
destination. Entries like../../etc/passwdare rejected withSecurityException("archive entry escapes destination"). - Tar symlinks and hardlinks: rejected outright. A tar containing links fails the whole extraction.
- Unreadable entries: an entry that commons-compress reports as undecodable (encrypted, unsupported compression) fails rather than being silently skipped.
The destination itself is validated against the same path allowlist as IdeFileService.
enum class ArchiveFormat {
XZ,
GZIP,
TAR,
TAR_XZ,
TAR_GZ,
ZIP
}
sealed class ExtractResult {
data class Success(val bytesWritten: Long, val filesExtracted: Int) : ExtractResult()
data class Failure(val error: Throwable) : ExtractResult()
}
Success.bytesWritten counts uncompressed bytes written to disk. Success.filesExtracted counts file entries (not directories). Failure.error carries the root cause: IOException, SecurityException, InterruptedException, or a format-specific exception.
Three new methods for binary payloads and cleanup.
Requires: filesystem.write or ide.environment.write.
interface IdeFileService {
// existing text methods unchanged
fun readFile(file: File): String?
fun writeFile(file: File, content: String): Boolean
fun appendToFile(file: File, content: String): Boolean
fun insertAfterPattern(file: File, pattern: String, content: String): Boolean
fun replaceInFile(file: File, oldText: String, newText: String): Boolean
// new
fun writeBinary(file: File, data: ByteArray): Boolean
fun writeStream(file: File, input: InputStream): Long
fun delete(file: File): Boolean
}
writeBinary(file, data) replaces the file contents with data. Use this for small binary blobs (shaders, configs, seed data). Do not pass a 200 MB ByteArray; use writeStream or IdeArchiveService.extract instead. Returns true on success.
writeStream(file, input) pipes input into file with a 64 KiB buffer. Preferred for any payload where you have an InputStream and don't want to buffer the full contents in memory. Returns the number of bytes written, or -1 on failure. Cooperates with Thread.interrupted() for cancellation. The caller owns input and must close it.
context.resources.openPluginAsset("seed.db")?.use { stream ->
val bytes = fileService.writeStream(File(env.getPluginDataDirectory(), "seed.db"), stream)
context.logger.info("wrote $bytes bytes")
}
delete(file) removes a file or directory. Directories are removed recursively. Returns true on success, including when the target did not exist (idempotent cleanup). Required for plugins that install files in activate() and need to remove them in deactivate(); the prior API had no delete method.
override fun deactivate(): Boolean {
val env = context.services.get(IdeEnvironmentService::class.java) ?: return true
val files = context.services.get(IdeFileService::class.java) ?: return true
files.delete(File(env.getNdkDirectory(), "cmake"))
return true
}
The default allowlist used by IdeFileService and IdeArchiveService now widens when the plugin holds IDE_ENVIRONMENT_WRITE.
Always allowed (any plugin with FILESYSTEM_WRITE):
/storage/emulated/0/CodeOnTheGoProjects/sdcard/CodeOnTheGoProjects$HOME/CodeOnTheGoProjects/tmp/CodeOnTheGoProject
Additionally allowed with IDE_ENVIRONMENT_WRITE:
- Anywhere under
$ANDROID_HOME(SDK, NDK, platforms, build-tools) - Anywhere under
$TMP_DIR - Anywhere under
$IDE_HOME/plugins/<pluginId>/(this plugin's dir only)
Writes outside these paths throw SecurityException("Plugin <id> does not have access to path: <absolute>") before any IO happens.
Monitors build status and events.
interface IdeBuildService {
fun isBuildInProgress(): Boolean
fun isToolingServerStarted(): Boolean
fun addBuildStatusListener(callback: BuildStatusListener)
fun removeBuildStatusListener(callback: BuildStatusListener)
}
isBuildInProgress() returns true if a build or sync is currently running.
isToolingServerStarted() returns true if the Gradle tooling server is started and ready.
addBuildStatusListener(callback) registers a callback to receive notifications when builds start, finish, or fail.
removeBuildStatusListener(callback) unregisters a previously registered callback.
Callback interface for build events.
interface BuildStatusListener {
fun onBuildStarted()
fun onBuildFinished()
fun onBuildFailed(error: String?)
}
onBuildStarted() called when a build begins.
onBuildFinished() called when a build completes successfully.
onBuildFailed(error: String?) called when a build fails or is cancelled. The error parameter contains the failure message, or null if cancelled.
Provides access to UI context.
interface IdeUIService {
fun getCurrentActivity(): Activity?
fun isUIAvailable(): Boolean
}
getCurrentActivity() returns the current Activity for showing dialogs. Returns null if no activity is available (e.g., app is in background).
isUIAvailable() returns true if it's safe to perform UI operations. Always check this before showing dialogs.
Manages plugin tabs in the editor.
interface IdeEditorTabService {
fun isPluginTab(tabId: String): Boolean
fun selectPluginTab(tabId: String): Boolean
fun getAllPluginTabIds(): List<String>
fun isTabSystemAvailable(): Boolean
}
isPluginTab(tabId: String) returns true if the given tab ID belongs to a plugin tab.
selectPluginTab(tabId: String) switches to the specified plugin tab, making it active. Returns true if successful.
getAllPluginTabIds() returns IDs of all currently open plugin tabs.
isTabSystemAvailable() returns true if the tab system is initialized and ready. Always check this before calling other methods.
Shows tooltips from plugin documentation.
interface IdeTooltipService {
fun showTooltip(anchorView: View, category: String, tag: String)
fun showTooltip(anchorView: View, tag: String)
}
showTooltip(anchorView, category, tag) shows a tooltip anchored to the specified view. The tooltip is looked up by category and tag.
showTooltip(anchorView, tag) shows a tooltip using the default IDE category.
All plugin metadata is declared in AndroidManifest.xml using <meta-data> tags.
Plugin.id - Unique identifier using reverse domain notation (e.g., com.example.myplugin)
plugin.name - Human-readable display name
plugin.version - Version string (e.g., 1.0.0)
plugin.description - Brief description of the plugin's purpose
plugin.author - Author name or organization
plugin.min_ide_version - Minimum required Code on the Go version
plugin.main_class - Fully qualified class name of your IPlugin implementation
plugin.max_ide_version - Maximum supported Code on the Go version
plugin.permissions - Comma-separated list of required permissions
plugin.dependencies - Comma-separated list of required plugin IDs
plugin.sidebar_items - Number of sidebar items your plugin adds (required if using getSideMenuItems)
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="My Plugin"
android:theme="@style/Theme.AppCompat">
<meta-data android:name="plugin.id" android:value="com.example.myplugin" />
<meta-data android:name="plugin.name" android:value="My Plugin" />
<meta-data android:name="plugin.version" android:value="1.0.0" />
<meta-data android:name="plugin.description" android:value="Does amazing things" />
<meta-data android:name="plugin.author" android:value="Your Name" />
<meta-data android:name="plugin.min_ide_version" android:value="1.0.0" />
<meta-data android:name="plugin.main_class" android:value="com.example.myplugin.MyPlugin" />
<meta-data android:name="plugin.permissions" android:value="filesystem.read,filesystem.write" />
<meta-data android:name="plugin.sidebar_items" android:value="1" />
</application>
</manifest>
If plugin.version is not explicitly set in the manifest (i.e., uses the ${pluginVersion} placeholder), the PluginBuilder Gradle plugin automatically generates a unique
version at build time in the format 1.0.0-build.YYYYMMDDHHmmss (e.g., 1.0.0-<debug|release>.20260321143022).
To use auto-versioning, set the manifest value to the placeholder:
<meta-data android:name="plugin.version" android:value="${pluginVersion}" />
// To set an explicit version, configure it in your build.gradle.kts:
pluginBuilder {
pluginName = "my-plugin"
pluginVersion = "2.1.0" // Overrides auto-generation
}
If pluginVersion is not set in the pluginBuilder {} block, every build gets a unique timestamp-based version automatically.
For documentation teams: Plugin tooltips are stored in plugin_documentation.db.
Stores unique category names.
CREATE TABLE PluginTooltipCategories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL UNIQUE
)
Stores tooltip content.
CREATE TABLE PluginTooltips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
categoryId INTEGER NOT NULL,
tag TEXT NOT NULL,
summary TEXT NOT NULL,
detail TEXT,
FOREIGN KEY(categoryId) REFERENCES PluginTooltipCategories(id),
UNIQUE(categoryId, tag)
)
Stores tooltip action buttons.
CREATE TABLE PluginTooltipButtons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tooltipId INTEGER NOT NULL,
description TEXT NOT NULL,
uri TEXT NOT NULL,
buttonNumberId INTEGER NOT NULL,
FOREIGN KEY(tooltipId) REFERENCES PluginTooltips(id) ON DELETE CASCADE
)
The main IDE uses documentation.db with categories: ide, java, kotlin, xml. Plugin categories should use plugin_<name> format.
Plugin fragments need the PluginFragmentHelper to access resources correctly:
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
class MyFragment : Fragment() {
private val pluginId = "com.example.myplugin"
override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
return PluginFragmentHelper.getPluginInflater(
pluginId,
super.onGetLayoutInflater(savedInstanceState)
)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.my_fragment, container, false)
}
}
Plugins can define custom themes to match the IDE's light and dark modes using Android resource qualifiers (values/ vs values-night/) and a PluginTheme style.
When a plugin fragment inflates its layout, the IDE creates a PluginResourceContext that:
- Syncs the plugin's
Resourceswith the current system configuration (including night mode) - Looks for a style named
PluginThemein the plugin's resources - If found, applies it as the plugin's theme. If not found, falls back to framework
Theme_Material/Theme_Material_Light
The plugin controls its own theming entirely through its own resources — no dependency on the host app's theme resource IDs.
Create src/main/res/values/styles.xml with a style named exactly PluginTheme. The recommended parent is Theme.Material3.DayNight.NoActionBar (requires com.google.android.material:material:1.10.0 dependency).
<style name="PluginTheme" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/plugin_primary</item>
<item name="colorOnPrimary">@color/plugin_on_primary</item>
<item name="android:colorPrimary">@color/plugin_primary</item>
<item name="android:colorAccent">@color/plugin_primary</item>
</style>
Override both colorPrimary (Material Components attribute) and android:colorPrimary (framework attribute) — these are separate attribute IDs and widgets may reference either one.
Define matching color resources in values/colors.xml and values-night/colors.xml. The IDE calls updateConfiguration() before theme resolution, so the correct qualifier is always selected.
The IDE's default BlueWave theme uses these primary colors:
| Light | Dark | |
|---|---|---|
plugin_primary |
#485D92 |
#B1C5FF |
plugin_on_primary |
#FFFFFF |
#172E60 |
Use ?android:attr/ references in layout XML. These resolve from the plugin's theme at inflation time.
| Attribute | Description |
|---|---|
?android:attr/colorBackground |
Main background color |
?android:attr/textColorPrimary |
Primary text color |
?android:attr/textColorSecondary |
Secondary/muted text color |
?android:attr/dividerHorizontal |
Divider drawable |
?android:attr/selectableItemBackground |
Ripple for clickable items |
?android:attr/buttonBarButtonStyle |
Flat button style |
Set explicit android:textColor on buttons using attributes like ?android:attr/textColorSecondary.
Use the view's context — not requireContext(). The view's context is the PluginResourceContext which can resolve plugin resource IDs. requireContext() returns the Activity and will throw Resources$NotFoundException.
// Correct
val color = ContextCompat.getColor(myView.context, R.color.my_color)
// Wrong — crashes
val color = ContextCompat.getColor(requireContext(), R.color.my_color)
For programmatic dark mode queries, IdeThemeService is available through the service registry.
-
isDarkMode(): Boolean: returns current dark mode state -
addThemeChangeListener(listener): notifies on theme changes -
removeThemeChangeListener(listener): unregisters listener
Remove listeners in deactivate() or dispose().
Code on the Go has a global experiments flag that controls visibility of early-stage or in-development features. Plugins can also query this flag to conditionally enable experimental functionality.
Plugins access the experiments flag through IdeFeatureFlagService, available from the ServiceRegistry.
interface IdeFeatureFlagService {
fun isExperimentsEnabled(): Boolean
}
Returns true if the user has enabled experiments on their device (i.e., the CodeOnTheGo.exp file exists in their Downloads). Returns false otherwise.
Retrieve the service during initialize() or activate() and use it to gate experimental features:
class MyPlugin : IPlugin {
private lateinit var context: PluginContext
override fun initialize(context: PluginContext): Boolean {
this.context = context
return true
}
override fun activate(): Boolean {
val featureFlags = context.services.get(IdeFeatureFlagService::class.java)
if (featureFlags?.isExperimentsEnabled() == true) {
context.logger.info("Experiments enabled, activating beta features")
enableBetaFeatures()
} else {
context.logger.info("Experiments disabled, running stable features only")
}
return true
}
}
Adds first-class API for plugins to declare build actions (shell commands and Gradle tasks), execute them with streaming output to the Build Output panel, and control toolbar action visibility.
Required permission: system.commands
interface BuildActionExtension : IPlugin {
fun getBuildActions(): List<PluginBuildAction>
fun toolbarActionsToHide(): Set<String> = emptySet()
fun onActionStarted(actionId: String) {}
fun onActionCompleted(actionId: String, result: CommandResult) {}
}
Returns build actions to show as toolbar buttons. Called each time the toolbar renders.
Returns built-in toolbar action IDs to hide while this plugin is active. Use ToolbarActionIds constants. Only IDs in ToolbarActionIds.ALL are accepted; unknown IDs are ignored.
Called when one of your actions begins executing.
Called when an action finishes. The result is CommandResult.Success, CommandResult.Failure, or CommandResult.Cancelled.
data class PluginBuildAction(
val id: String,
val name: String,
val description: String,
val icon: Int? = null,
val category: BuildActionCategory = BuildActionCategory.CUSTOM,
val command: CommandSpec,
val timeoutMs: Long = 600_000
)
id: Unique identifier within your plugin.
name: Toolbar button tooltip label.
description: Human-readable description.
icon: Drawable resource ID (R.drawable.xxx). Should be a 24dp vector with fillColor="@android:color/white". The IDE applies theme-aware tinting at runtime. Defaults to the built-in run icon if null.
category: BUILD, TEST, DEPLOY, LINT, or CUSTOM. Informational only.
command: A CommandSpec.ShellCommand or CommandSpec.GradleTask.
timeoutMs: Max execution time in milliseconds. Default 10 minutes. Command is killed on timeout.
sealed class CommandSpec {
data class ShellCommand(
val executable: String,
val arguments: List<String> = emptyList(),
val workingDirectory: String? = null,
val environment: Map<String, String> = emptyMap()
) : CommandSpec()
data class GradleTask(
val taskPath: String,
val arguments: List<String> = emptyList()
) : CommandSpec()
}
ShellCommand: Runs a command via ProcessBuilder. workingDirectory defaults to the project root and must be within the project directory.
GradleTask: Runs a Gradle task using the project's gradlew wrapper. taskPath examples: "lint", "assembleDebug", ":app:test".
Received in onActionCompleted().
Success: Exit code 0. Contains exitCode, stdout, stderr, durationMs.
Failure: Non-zero exit, timeout, or failed to start. Contains exitCode, stdout, stderr, error (additional context), durationMs.
Cancelled: User cancelled or plugin unloaded. Contains partialStdout, partialStderr.
Constants for all built-in toolbar actions, used with toolbarActionsToHide().
| Constant | ID |
|---|---|
QUICK_RUN |
ide.editor.build.quickRun |
PROJECT_SYNC |
ide.editor.syncProject |
DEBUG |
ide.editor.build.debug |
RUN_TASKS |
ide.editor.build.runTasks |
UNDO |
ide.editor.code.text.undo |
REDO |
ide.editor.code.text.redo |
SAVE |
ide.editor.files.saveAll |
PREVIEW_LAYOUT |
ide.editor.previewLayout |
FIND |
ide.editor.find |
FIND_IN_FILE |
ide.editor.find.inFile |
FIND_IN_PROJECT |
ide.editor.find.inProject |
LAUNCH_APP |
ide.editor.launchInstalledApp |
DISCONNECT_LOG_SENDERS |
ide.editor.service.logreceiver.disconnectSenders |
GENERATE_XML |
ide.editor.generatexml |
Service for executing commands programmatically. Available via ServiceRegistry. Use this when you need more control than the declarative getBuildActions() approach; for example, chaining commands or processing output in code.
Required permission: system.commands
interface IdeCommandService {
fun executeCommand(spec: CommandSpec, timeoutMs: Long = 600_000): CommandExecution
fun isCommandRunning(executionId: String): Boolean
fun cancelCommand(executionId: String): Boolean
fun getRunningCommandCount(): Int
}
executeCommand() starts a command and returns a CommandExecution handle. Throws SecurityException without SYSTEM_COMMANDS permission.
interface CommandExecution {
val executionId: String
val output: Flow<CommandOutput>
suspend fun await(): CommandResult
fun cancel()
}
output: Kotlin Flow emitting CommandOutput.StdOut, CommandOutput.StdErr, and CommandOutput.ExitCode in real time.
await(): Suspends until the command completes and returns CommandResult.
cancel(): Forcibly terminates the command. await() returns CommandResult.Cancelled.
When a plugin build action is triggered:
- Progress indicator appears at the top of the editor
- Bottom sheet expands to the Build Output tab
- Toolbar icon changes to a red stop icon (tap to cancel)
- Output streams line-by-line into Build Output
- On completion, progress hides and icon reverts
| Constraint | Detail |
|---|---|
| Permission |
system.commands required |
| Working directory | Must be within the project directory |
| Concurrency | Max 3 commands per plugin |
| Timeout | Configurable per action, default 10 min |
| Output cap | 10MB max stdout/stderr |
| Toolbar hiding | Only ToolbarActionIds.ALL values accepted |
Add system.commands to your permissions:
<meta-data android:name="plugin.permissions" android:value="system.commands,filesystem.read" />
Plugins can also read a .codeonthego/scripts.json file from the project root to let users define their own commands. This is the recommended pattern for plugins that support arbitrary project types.
{
"scripts": [
{
"name": "Run App",
"command": "node app.js",
"icon": "run"
},
{
"name": "Install Deps",
"command": "npm install",
"icon": "download"
},
{
"name": "Run Tests",
"command": "npm test",
"icon": "test"
}
]
}
name: Toolbar button label.
command: Shell command to execute. Supports pipes, redirects, and chained commands (run via sh -c).
icon: Maps to a bundled icon set: run (play), build (wrench), test (checkmark), download (arrow), clean (trash), deploy (cloud upload). Defaults to run.
class CMakePlugin : IPlugin, BuildActionExtension {
private lateinit var context: PluginContext
override fun initialize(context: PluginContext): Boolean {
this.context = context; return true
}
override fun activate(): Boolean = true
override fun deactivate(): Boolean = true
override fun dispose() {}
override fun getBuildActions() = listOf(
PluginBuildAction(
id = "cmake-build", name = "CMake Build",
description = "Build with CMake",
icon = R.drawable.ic_build,
category = BuildActionCategory.BUILD,
command = CommandSpec.ShellCommand("cmake", listOf("--build", "build/"))
)
)
override fun toolbarActionsToHide() = setOf(
ToolbarActionIds.QUICK_RUN,
ToolbarActionIds.DEBUG,
ToolbarActionIds.RUN_TASKS
)
override fun onActionCompleted(actionId: String, result: CommandResult) {
when (result) {
is CommandResult.Success -> context.logger.info("Done in ${result.durationMs}ms")
is CommandResult.Failure -> context.logger.error("Failed: ${result.error}")
is CommandResult.Cancelled -> context.logger.info("Cancelled")
}
}
}
Plugins can contribute code completion snippets to any language via SnippetExtension.
Implement this interface alongside IPlugin to contribute snippets.
interface SnippetExtension : IPlugin {
fun getSnippetContributions(): List<SnippetContribution>
}
Called during plugin activation. Returns all snippets your plugin contributes. Each entry maps to a single auto-complete suggestion in the editor.
class MyPlugin : IPlugin, SnippetExtension {
private lateinit var context: PluginContext
override fun initialize(context: PluginContext): Boolean {
this.context = context
return true
}
override fun activate(): Boolean = true
override fun deactivate(): Boolean = true
override fun dispose() {}
override fun getSnippetContributions() = listOf(
SnippetContribution(
language = "kotlin",
scope = "kotlin",
prefix = "logd",
description = "Log a debug message",
body = listOf("Log.d(\"${1:TAG}\", \"${2:message}\")")
),
SnippetContribution(
language = "java",
scope = "java",
prefix = "sout",
description = "Print to stdout",
body = listOf("System.out.println(${1:message});")
)
)
}
Data class representing a single snippet.
data class SnippetContribution(
val language: String,
val scope: String,
val prefix: String,
val description: String,
val body: List<String>,
)
language: Target language id (e.g. "kotlin", "java", "xml"). Must match the editor's language identifier.
scope: Scope within the language. Use the same value as language for file-level scope.
prefix: The trigger text the user types to invoke auto-complete (e.g. "logd").
description: Short label shown in the auto-complete dropdown.
body: Lines of the snippet body. Uses TextMate snippet syntax.
The body uses dollar-sign syntax; the same standard as Android Studio live templates, so existing snippets can be reused without modification.
| Syntax | Meaning |
|---|---|
$1, $2 … |
Tab stops cursor jumps here in order when Tab is pressed |
$0 |
Final cursor position |
${1:placeholder} |
Tab stop with default text |
$TM_FILENAME |
Current file name |
$TM_LINE_NUMBER |
Current line number |
Confirmed supported: plain text, variables, tab stops, placeholders.
Likely supported: upper/lower case transformations.
Multi-line snippets use one string per line:
body = listOf(
"fun ${1:name}(${2:params}): ${3:Unit} {",
"\t$0",
"}"
)
Call this service to push updated snippets at runtime without requiring a plugin reload.
interface IdeSnippetService {
fun refreshSnippets(pluginId: String)
}
Re-invokes getSnippetContributions() for the given plugin and reloads the registry. Call this after dynamically changing which snippets your plugin provides (e.g. after the user edits a config file).
val snippetService = context.services.get(IdeSnippetService::class.java)
snippetService?.refreshSnippets(context.pluginId)
- Open the Run Gradle Task action in Code On The Go (find it in the toolbar)
- Filter for
assemblePlugin - Select and run the
assemblePlugintask
This builds a plugin file (a .cgp) file in build/plugin/.
You have two options:
Option 1: Run the assemblePluginDebug Gradle task the same way as above
Option 2: Click the green run button in the toolbar to quickly build and run a debug version
The debug plugin will be created as <pluginname>-debug.cgp in build/plugin/.
After building, find your plugin at:
<your-plugin-project>/build/plugin/
myplugin.cgp # Release build
myplugin-debug.cgp # Debug build
Last updated: 21 Apr 2026