diff --git a/README.md b/README.md index 2e4bffec..9fd91476 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Android also accepts shared text, images and application files. Web content is s | --- | --- | --- | --- | --- | --- | | ChatGPT Web | Full | Provider WebView | Yes | Generating + unread | Persistent session; mobile/desktop mode; Codex Cloud shortcut | | Claude Web | Full | Provider WebView | Yes | Generating + unread | Persistent session | +| Claude Code (official app) | External handoff | Claude-owned app/site | Via Claude | Not tracked by Aistee | Studio Tools → Agents; optional plan/code task, GitHub repo + branch, explicit confirmation, no token proxy | | Gemini Chat Web | Partial | Fresh Google OAuth may reject embedded user-agents | Yes | Generating + unread | Existing sessions work; embedded OAuth is provider-limited | | DeepSeek Web | Full | Provider WebView | Yes | Generating + unread | Persistent session | | Kimi Web | Full | Provider WebView | Yes | Generating + unread | Persistent session | diff --git a/app/src/main/java/ais/tee/ui/screens/ClaudeCodeHandoffPanel.kt b/app/src/main/java/ais/tee/ui/screens/ClaudeCodeHandoffPanel.kt new file mode 100644 index 00000000..715f71a0 --- /dev/null +++ b/app/src/main/java/ais/tee/ui/screens/ClaudeCodeHandoffPanel.kt @@ -0,0 +1,195 @@ +package ais.tee.ui.screens + +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import java.net.URLEncoder + +/** Only official Claude Code links. Aistee does not access Claude authentication or run a proxy. */ +internal object ClaudeCodeHandoff { + const val SESSIONS_URL = "claude://code" + const val WEB_URL = "https://claude.ai/code" + private const val NEW_SESSION_URL = "https://claude.ai/code/new" + const val MAX_TASK_CHARS = 2_000 + private val ownerPattern = Regex("^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$") + private val repoPattern = Regex("^[A-Za-z0-9._-]{1,100}$") + + private fun validRepository(value: String): Boolean { + val parts = value.split("/") + return parts.size == 2 && ownerPattern.matches(parts[0]) && + repoPattern.matches(parts[1]) && parts[1] !in setOf(".", "..") + } + + private fun validBranch(value: String): Boolean { + if (value.isEmpty() || value.length > 150 || value == "@" || + value.startsWith("-") || value.startsWith("/") || value.endsWith("/") + ) return false + if (value.contains("..") || value.contains("@{") || value.contains("//")) return false + if (value.any { it.isWhitespace() || it.code < 32 || it.code == 127 || + it.code == 92 || it in "~^:?*[" + } + ) return false + return value.split("/").all { part -> + part.isNotEmpty() && !part.startsWith(".") && !part.endsWith(".") && + !part.endsWith(".lock") + } + } + + enum class Mode(val value: String) { PLAN("plan"), CODE("code") } + + fun canOpen(task: String, repository: String, branch: String): Boolean { + val repo = repository.trim() + val ref = branch.trim() + return task.isNotBlank() && task.length <= MAX_TASK_CHARS && + (repo.isEmpty() || validRepository(repo)) && + (ref.isEmpty() || (repo.isNotEmpty() && validBranch(ref))) + } + + fun newSessionUrl(task: String, repository: String = "", branch: String = "", mode: Mode = Mode.PLAN): String { + require(canOpen(task, repository, branch)) { "Invalid Claude Code handoff fields" } + fun encode(value: String): String = URLEncoder.encode(value, "UTF-8").replace("+", "%20") + return buildString { + append(NEW_SESSION_URL) + append("?q=").append(encode(task.trim())) + append("&mode=").append(mode.value) + if (repository.isNotBlank()) { + append("&repo=").append(encode(repository.trim())) + if (branch.isNotBlank()) append("&branch=").append(encode(branch.trim())) + } + } + } +} + +/** Optional subscription-account handoff inside the maintained Studio Tools hub. */ +@Composable +internal fun ClaudeCodeHandoffPanel(modifier: Modifier = Modifier) { + val context = LocalContext.current + var task by rememberSaveable { mutableStateOf("") } + var repository by rememberSaveable { mutableStateOf("") } + var branch by rememberSaveable { mutableStateOf("") } + var mode by rememberSaveable { mutableStateOf(ClaudeCodeHandoff.Mode.PLAN) } + var confirmation by remember { mutableStateOf(false) } + var notice by remember { mutableStateOf(null) } + + Column( + modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp) + .testTag("claude_code_handoff"), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Claude Code", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + "Open a task in the official Claude app or website, using your own Claude account. " + + "No Aistee API key, token transfer or paired computer is needed.", + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + value = task, + onValueChange = { if (it.length <= ClaudeCodeHandoff.MAX_TASK_CHARS) task = it }, + label = { Text("Task") }, + minLines = 3, + maxLines = 6, + modifier = Modifier.fillMaxWidth().testTag("claude_code_task"), + ) + OutlinedTextField( + value = repository, + onValueChange = { repository = it; if (it.isBlank()) branch = "" }, + label = { Text("GitHub owner/repository (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("claude_code_repo"), + ) + OutlinedTextField( + value = branch, + onValueChange = { branch = it }, + label = { Text("Branch (optional)") }, + enabled = repository.isNotBlank(), + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("claude_code_branch"), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ClaudeCodeHandoff.Mode.entries.forEach { choice -> + FilterChip( + selected = choice == mode, + onClick = { mode = choice }, + label = { Text(if (choice == ClaudeCodeHandoff.Mode.PLAN) "Plan" else "Code") }, + ) + } + } + Text( + "Claude controls access, usage limits and optional extra credits. " + + "No local Aistee files or chats are attached.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { confirmation = true }, + enabled = ClaudeCodeHandoff.canOpen(task, repository, branch), + modifier = Modifier.testTag("claude_code_open"), + ) { Text("Open in Claude Code") } + TextButton(onClick = { + notice = if (openExternalUri(context, Uri.parse(ClaudeCodeHandoff.SESSIONS_URL))) { + null + } else if (openExternalUri(context, Uri.parse(ClaudeCodeHandoff.WEB_URL))) { + "Claude app unavailable. Opened the Claude Code website instead." + } else { + "No app or browser could open Claude Code." + } + }) { Text("Open Claude Code sessions") } + notice?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + + if (confirmation) { + AlertDialog( + onDismissRequest = { confirmation = false }, + title = { Text("Send task to Claude?") }, + text = { + Column( + modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Review everything sent to the official Claude Code composer:") + Text("Mode: " + mode.value) + if (repository.isNotBlank()) Text("Repository: " + repository.trim()) + if (branch.isNotBlank()) Text("Branch: " + branch.trim()) + Text("Task:", fontWeight = FontWeight.Bold) + Text(task, style = MaterialTheme.typography.bodySmall) + } + }, + confirmButton = { + TextButton(onClick = { + confirmation = false + val url = ClaudeCodeHandoff.newSessionUrl(task, repository, branch, mode) + notice = if (openExternalUri(context, Uri.parse(url))) null + else "No app or browser could open Claude Code." + }) { Text("Open Claude") } + }, + dismissButton = { + TextButton(onClick = { confirmation = false }) { Text("Cancel") } + }, + ) + } +} diff --git a/app/src/main/java/ais/tee/ui/screens/StudioToolsHubScreen.kt b/app/src/main/java/ais/tee/ui/screens/StudioToolsHubScreen.kt index bad69b24..6a1f9968 100644 --- a/app/src/main/java/ais/tee/ui/screens/StudioToolsHubScreen.kt +++ b/app/src/main/java/ais/tee/ui/screens/StudioToolsHubScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import ais.tee.ui.viewmodel.StudioViewModel @@ -16,6 +17,7 @@ import ais.tee.ui.viewmodel.StudioViewModel internal enum class StudioToolsSection { SKILLS, BENCHES, + AGENTS, JOBS, PRIVACY } @@ -31,6 +33,7 @@ internal enum class StudioToolsSection { @Composable fun SkillsBrowserScreen(viewModel: StudioViewModel) { var section by rememberSaveable { mutableStateOf(StudioToolsSection.SKILLS) } + val stateHolder = rememberSaveableStateHolder() Column(modifier = Modifier.fillMaxSize()) { PrimaryTabRow(selectedTabIndex = section.ordinal) { @@ -43,6 +46,7 @@ fun SkillsBrowserScreen(viewModel: StudioViewModel) { when (candidate) { StudioToolsSection.SKILLS -> "Skills" StudioToolsSection.BENCHES -> "Benches" + StudioToolsSection.AGENTS -> "Agents" StudioToolsSection.JOBS -> "Jobs" StudioToolsSection.PRIVACY -> "Privacy" } @@ -60,6 +64,9 @@ fun SkillsBrowserScreen(viewModel: StudioViewModel) { StudioToolsSection.BENCHES -> BenchToolsScreen( modifier = Modifier.weight(1f) ) + StudioToolsSection.AGENTS -> stateHolder.SaveableStateProvider("claude-code") { + ClaudeCodeHandoffPanel(modifier = Modifier.weight(1f)) + } StudioToolsSection.JOBS -> AsyncJobsPanel( viewModel = viewModel, modifier = Modifier.weight(1f) diff --git a/app/src/main/java/ais/tee/ui/screens/WebNavigationSafety.kt b/app/src/main/java/ais/tee/ui/screens/WebNavigationSafety.kt index 0d2658e2..910d08ed 100644 --- a/app/src/main/java/ais/tee/ui/screens/WebNavigationSafety.kt +++ b/app/src/main/java/ais/tee/ui/screens/WebNavigationSafety.kt @@ -66,8 +66,8 @@ internal fun openExternalUri(context: Context, uri: Uri): Boolean { } val error = runCatching { context.startActivity(intent) }.exceptionOrNull() ?: return true when (error) { - is ActivityNotFoundException -> Log.w(WEBVIEW_LOG_TAG, "External URI handler unavailable", error) - is SecurityException -> Log.w(WEBVIEW_LOG_TAG, "External URI launch rejected", error) + is ActivityNotFoundException -> Log.w(WEBVIEW_LOG_TAG, "External URI handler unavailable") + is SecurityException -> Log.w(WEBVIEW_LOG_TAG, "External URI launch rejected") else -> throw error } return false diff --git a/app/src/test/java/ais/tee/ui/screens/ClaudeCodeHandoffTest.kt b/app/src/test/java/ais/tee/ui/screens/ClaudeCodeHandoffTest.kt new file mode 100644 index 00000000..a60cc31e --- /dev/null +++ b/app/src/test/java/ais/tee/ui/screens/ClaudeCodeHandoffTest.kt @@ -0,0 +1,51 @@ +package ais.tee.ui.screens + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class ClaudeCodeHandoffTest { + @Test + fun officialLinkEncodesTaskAndGitHubContextWithoutAnyCredentials() { + val link = ClaudeCodeHandoff.newSessionUrl( + task = "Fix failing CI & add tests", + repository = "travnie/aistee", + branch = "feat/agent-lab", + ) + assertEquals( + "https://claude.ai/code/new?q=Fix%20failing%20CI%20%26%20add%20tests" + + "&mode=plan&repo=travnie%2Faistee&branch=feat%2Fagent-lab", + link, + ) + assertFalse(link.contains("token")) + assertFalse(link.contains("api_key")) + } + + @Test + fun codeModeAndNoRepositoryRemainValid() { + assertEquals( + "https://claude.ai/code/new?q=Review%20a%20patch&mode=code", + ClaudeCodeHandoff.newSessionUrl(" Review a patch ", mode = ClaudeCodeHandoff.Mode.CODE), + ) + assertEquals("claude://code", ClaudeCodeHandoff.SESSIONS_URL) + assertEquals("https://claude.ai/code", ClaudeCodeHandoff.WEB_URL) + } + + @Test + fun invalidTaskAndReferencesCannotOpen() { + assertFalse(ClaudeCodeHandoff.canOpen(" ", "", "")) + assertFalse(ClaudeCodeHandoff.canOpen("x".repeat(2_001), "", "")) + assertFalse(ClaudeCodeHandoff.canOpen("task", "invalid-repo", "")) + assertFalse(ClaudeCodeHandoff.canOpen("task", "", "main")) + assertFalse(ClaudeCodeHandoff.canOpen("task", "trvny/repo", "bad branch?name")) + assertThrows(IllegalArgumentException::class.java) { + ClaudeCodeHandoff.newSessionUrl("task", "bad/repo/extra") + } + assertTrue(ClaudeCodeHandoff.canOpen("task", "trvny/repo", "feature/task")) + assertTrue(ClaudeCodeHandoff.canOpen("task", "trvny/repo", "release+hotfix@2")) + assertFalse(ClaudeCodeHandoff.canOpen("task", "trvny/repo", "bad..ref")) + assertFalse(ClaudeCodeHandoff.canOpen("task", "trvny/repo", "dir/.hidden")) + } +} diff --git a/app/src/test/java/ais/tee/ui/screens/StudioToolsHubScreenTest.kt b/app/src/test/java/ais/tee/ui/screens/StudioToolsHubScreenTest.kt index 87a208ee..eed796c1 100644 --- a/app/src/test/java/ais/tee/ui/screens/StudioToolsHubScreenTest.kt +++ b/app/src/test/java/ais/tee/ui/screens/StudioToolsHubScreenTest.kt @@ -8,4 +8,8 @@ class StudioToolsHubScreenTest { fun skillsRemainTheFirstStudioToolsSection() { assertEquals(StudioToolsSection.SKILLS, StudioToolsSection.entries.first()) } + @Test + fun agentsAreAnOptionalSectionOfTheExistingToolsHub() { + assertEquals(StudioToolsSection.AGENTS, StudioToolsSection.entries[2]) + } } diff --git a/docs/provider-runtime.md b/docs/provider-runtime.md index eff8d071..6eafb621 100644 --- a/docs/provider-runtime.md +++ b/docs/provider-runtime.md @@ -215,3 +215,11 @@ Native Android tool calling now uses a separate namespaced tool schema on the `A **Claude and Google subscription boundaries (verified October 2026):** Anthropic's [Help Center](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan) says subscription allowances may cover Claude Agent SDK / `claude -p` / third-party usage, while its [legal guidance](https://code.claude.com/docs/en/legal-and-compliance) prohibits a third-party app from offering Claude.ai sign-in or brokering Claude consumer tokens. Do not implement direct Claude subscription OAuth or credential transfer in Aistee. An independently authenticated, unmodified Claude Code runtime is a possible optional adapter, subject to Anthropic's current terms. Google [Antigravity FAQ](https://www.antigravity.google/docs/faq/) and [terms](https://antigravity.google/terms) prohibit consumer-plan third-party bridging; use the official app separately or a distinct API-key route. Neither subscription route may silently incur metered API usage. Protocol reference: https://developers.openai.com/siwc/token-sharing-open-source/codex-app-server + +### Claude Code on Android (official account handoff) + +Studio Tools → Agents now offers **Claude Code** as an explicit, outbound handoff through the official https://claude.ai/code/new universal link. The user composes a task in Aistee, optionally selects a GitHub owner/repository and branch, and chooses Plan (the default) or Code mode. A confirmation gate shows the text before opening the Claude app or browser. The separate Open Claude Code sessions action uses the official claude://code Android deep link, falling back to the Claude Code website if the app is unavailable (the HTTPS fallback may open the new-session composer). There is no silent sharing of existing native chats, files, API keys or tokens. Claude handles login, model access, session storage, plan allowance and any extra usage on its own site/app. + +This is an **independent Android entry point**, not a Claude inference adapter, API proxy, Agent SDK client or remote shell bridge. It works without Legion or any paired computer, subject to Claude Code availability on the user's account. It never bypasses Anthropic's published restrictions on third-party Claude OAuth or consumer credential brokerage. + +Official documented URL parameters: https://support.claude.com/en/articles/14898120-open-the-claude-mobile-app-with-a-link . Future optional CLI support may integrate only the unmodified Claude Code binary independently authenticated by its end user, with consent and transport security; it must not replace the ordinary standalone Aistee workflow or silently fall back to metered Claude API billing.