-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add visiontest init command for project-level agent setup #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -266,4 +266,3 @@ jobs: | |
| release-staging/ios-automation-server.tar.gz.sha256 | ||
| install.sh | ||
| run-visiontest.sh | ||
| AGENT_INSTRUCTIONS.md | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| package com.example.visiontest.cli.commands | ||
|
|
||
| import com.github.ajalt.clikt.core.CliktCommand | ||
| import com.github.ajalt.clikt.core.CliktError | ||
| import com.github.ajalt.clikt.core.UsageError | ||
| import com.github.ajalt.clikt.parameters.options.option | ||
| import com.github.ajalt.clikt.parameters.options.required | ||
| import com.github.ajalt.clikt.parameters.options.split | ||
| import java.nio.file.Path | ||
| import kotlin.io.path.createDirectories | ||
| import kotlin.io.path.writeText | ||
|
|
||
| /** | ||
| * `visiontest init --agent claude,opencode,codex` | ||
| * | ||
| * Writes a project-level SKILL.md for each specified agent so that AI coding | ||
| * assistants discover VisionTest CLI instructions automatically. | ||
| * | ||
| * Does NOT require `--platform` because this is not a device operation. | ||
| */ | ||
| class InitCommand( | ||
| private val workingDir: Path = Path.of(System.getProperty("user.dir")), | ||
| private val resourceLoader: (String) -> String? = Companion::loadClasspathResource, | ||
| ) : CliktCommand(name = "init", help = "Set up AI agent skill files in the current project") { | ||
|
|
||
| companion object { | ||
| private const val RESOURCE_PATH = "agent-instructions.md" | ||
|
|
||
| /** Agent name → relative SKILL.md path from project root. */ | ||
| val AGENT_PATHS: Map<String, String> = mapOf( | ||
| "claude" to ".claude/skills/visiontest/SKILL.md", | ||
| "opencode" to ".opencode/skills/visiontest/SKILL.md", | ||
| "codex" to ".agents/skills/visiontest/SKILL.md", | ||
| ) | ||
|
|
||
| private val YAML_FRONTMATTER = """ | ||
| |--- | ||
| |name: visiontest | ||
| |description: VisionTest mobile automation CLI – commands, workflows, and examples for automating Android and iOS devices. | ||
| |--- | ||
| | | ||
| """.trimMargin() | ||
|
|
||
| fun loadClasspathResource(name: String): String? = | ||
| InitCommand::class.java.classLoader.getResourceAsStream(name) | ||
| ?.bufferedReader() | ||
| ?.use { it.readText() } | ||
| } | ||
|
|
||
| private val agents by option("--agent", help = "Comma-separated agent names: claude, opencode, codex") | ||
| .split(",") | ||
| .required() | ||
|
|
||
| override fun run() { | ||
| // Validate agent names | ||
| val invalid = agents.filter { it.isBlank() || it !in AGENT_PATHS } | ||
| if (invalid.isNotEmpty()) { | ||
| throw UsageError( | ||
| "Unknown agent(s): ${invalid.joinToString()}. Valid agents: ${AGENT_PATHS.keys.joinToString()}" | ||
| ) | ||
| } | ||
|
Comment on lines
+55
to
+61
|
||
|
|
||
| val instructions = resourceLoader(RESOURCE_PATH) | ||
| ?: throw CliktError("Internal error: embedded resource '$RESOURCE_PATH' not found in JAR") | ||
|
|
||
| val content = YAML_FRONTMATTER + instructions | ||
|
|
||
| for (agent in agents) { | ||
| val relativePath = AGENT_PATHS.getValue(agent) | ||
| val target = workingDir.resolve(relativePath) | ||
| target.parent.createDirectories() | ||
| target.writeText(content) | ||
| echo(" wrote $target") | ||
| } | ||
|
|
||
| echo("Initialized ${agents.size} agent skill file(s).") | ||
| } | ||
|
Comment on lines
+54
to
+77
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| package com.example.visiontest.cli | ||
|
|
||
| import com.example.visiontest.cli.commands.InitCommand | ||
| import com.github.ajalt.clikt.core.MissingOption | ||
| import com.github.ajalt.clikt.core.UsageError | ||
| import org.junit.jupiter.api.io.TempDir | ||
| import java.nio.file.Path | ||
| import kotlin.io.path.readText | ||
| import kotlin.io.path.exists | ||
| import kotlin.test.Test | ||
| import kotlin.test.assertEquals | ||
| import kotlin.test.assertFailsWith | ||
| import kotlin.test.assertTrue | ||
| import kotlin.test.assertContains | ||
|
|
||
| class InitCommandTest { | ||
|
|
||
| @TempDir | ||
| lateinit var tmp: Path | ||
|
|
||
| private val fakeInstructions = "# Fake Instructions\nSome content here." | ||
|
|
||
| private fun createCommand(dir: Path = tmp) = InitCommand( | ||
| workingDir = dir, | ||
| resourceLoader = { fakeInstructions }, | ||
| ) | ||
|
|
||
| // --- resource loads from classpath --- | ||
|
|
||
| @Test | ||
| fun `embedded resource loads from classpath`() { | ||
| val content = InitCommand.loadClasspathResource("agent-instructions.md") | ||
| assertTrue(content != null && content.contains("VisionTest"), "Resource should contain VisionTest content") | ||
| } | ||
|
|
||
| // --- single agent writes correct file --- | ||
|
|
||
| @Test | ||
| fun `single agent writes SKILL file to correct path`() { | ||
| val cmd = createCommand() | ||
| cmd.parse(listOf("--agent", "claude")) | ||
|
|
||
| val file = tmp.resolve(".claude/skills/visiontest/SKILL.md") | ||
| assertTrue(file.exists(), "SKILL.md should be created") | ||
| val content = file.readText() | ||
| assertTrue(content.startsWith("---\n"), "SKILL.md should start with YAML frontmatter delimiter") | ||
| assertContains(content, "\n---\n", message = "SKILL.md should have closing frontmatter delimiter") | ||
| assertContains(content, "name: visiontest") | ||
| assertContains(content, fakeInstructions) | ||
| } | ||
|
|
||
| // --- multiple agents --- | ||
|
|
||
| @Test | ||
| fun `comma-separated agents writes all files`() { | ||
| val cmd = createCommand() | ||
| cmd.parse(listOf("--agent", "claude,opencode,codex")) | ||
|
|
||
| for ((agent, path) in InitCommand.AGENT_PATHS) { | ||
| assertTrue(tmp.resolve(path).exists(), "SKILL.md should exist for $agent") | ||
| } | ||
| } | ||
|
|
||
| // --- invalid agent name --- | ||
|
|
||
| @Test | ||
| fun `invalid agent name produces UsageError`() { | ||
| val cmd = createCommand() | ||
| val ex = assertFailsWith<UsageError> { | ||
| cmd.parse(listOf("--agent", "gemini")) | ||
| } | ||
| assertContains(ex.message ?: "", "Unknown agent") | ||
| } | ||
|
|
||
| // --- blank agent name in comma list --- | ||
|
|
||
| @Test | ||
| fun `blank agent name in comma list produces UsageError`() { | ||
| val cmd = createCommand() | ||
| val ex = assertFailsWith<UsageError> { | ||
| cmd.parse(listOf("--agent", ",claude")) | ||
| } | ||
| assertContains(ex.message ?: "", "Unknown agent") | ||
| } | ||
|
|
||
| // --- missing --agent --- | ||
|
|
||
| @Test | ||
| fun `missing agent flag produces MissingOption`() { | ||
| val cmd = createCommand() | ||
| assertFailsWith<MissingOption> { | ||
| cmd.parse(emptyList()) | ||
| } | ||
| } | ||
|
|
||
| // --- idempotent overwrite --- | ||
|
|
||
| @Test | ||
| fun `running init twice produces same content`() { | ||
| val cmd1 = createCommand() | ||
| cmd1.parse(listOf("--agent", "claude")) | ||
| val first = tmp.resolve(".claude/skills/visiontest/SKILL.md").readText() | ||
|
|
||
| val cmd2 = createCommand() | ||
| cmd2.parse(listOf("--agent", "claude")) | ||
| val second = tmp.resolve(".claude/skills/visiontest/SKILL.md").readText() | ||
|
|
||
| assertEquals(first, second) | ||
| } | ||
|
|
||
| // --- agent path mapping --- | ||
|
|
||
| @Test | ||
| fun `agent paths map correctly`() { | ||
| assertEquals(".claude/skills/visiontest/SKILL.md", InitCommand.AGENT_PATHS["claude"]) | ||
| assertEquals(".opencode/skills/visiontest/SKILL.md", InitCommand.AGENT_PATHS["opencode"]) | ||
| assertEquals(".agents/skills/visiontest/SKILL.md", InitCommand.AGENT_PATHS["codex"]) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
--agentvalues are split on commas but not trimmed, so inputs like--agent claude, opencodewill produce an unexpected “unknown agent” error because of the leading space. Consider trimming each entry after splitting (and validating on the trimmed values) to make the flag more robust.