Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ jobs:
- name: Run check task
run: ./gradlew check

- name: Generate CLI man page
run: ./gradlew :cli:generateManPage --no-configuration-cache

- name: Upload CLI man page
uses: actions/upload-artifact@v4
with:
name: cli-manpage
path: |
cli/build/man/cag.1
retention-days: 30

- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
Expand Down
116 changes: 68 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,73 +44,93 @@ java -jar "cli/build/libs/cli-all.jar" --new-feature --name=MyFeature
java -jar "cli/build/libs/cli-all.jar" --new-view-model --name=MyViewModel
```

#### Options
#### Usage and help

Usage:
Usage (canonical):

```bash
cag [--new-architecture [--no-compose] [--ktlint] [--detekt]]... [--new-feature --name=FeatureName [--package=PackageName]]... [--new-datasource --name=DataSourceName [--with=ktor|retrofit|ktor,retrofit]]... [--new-use-case --name=UseCaseName [--path=TargetPath]]... [--new-view-model --name=ViewModelName [--path=TargetPath]]...
cag [--new-project --name=ProjectName --package=PackageName [--no-compose] [--ktlint] [--detekt] [--ktor] [--retrofit]]... [--new-architecture [--no-compose] [--ktlint] [--detekt]]... [--new-feature --name=FeatureName [--package=PackageName]]... [--new-datasource --name=DataSourceName [--with=ktor|retrofit|ktor,retrofit]]... [--new-use-case --name=UseCaseName [--path=TargetPath]]... [--new-view-model --name=ViewModelName [--path=TargetPath]]...
```

##### New Architecture Options
- Full reference: `cag --help`
- Topic help: `cag --help --topic=new-feature` or `cag --help -t new-use-case`
- Man page: `man cag` (see below for generating/installing locally)

Common examples:

```bash
--new-architecture | -na
Generate a new Clean Architecture package with domain, presentation, and UI layers
--no-compose | -nc
Disable Compose support for the preceding architecture package
--ktlint | -kl
Enable ktlint for the preceding architecture package
--detekt | -d
Enable detekt for the preceding architecture package
# Generate a new project
cag --new-project --name=MyApp --package=com.example.myapp

# Add architecture to an existing project/module
cag --new-architecture --ktlint --detekt

# Add a new feature
cag --new-feature --name=Profile --package=com.example.feature.profile

# Add a data source with Retrofit
cag --new-datasource --name=User --with=retrofit

# Add a use case
cag --new-use-case --name=FetchUser --path=architecture/domain/src/main/kotlin

# Add a ViewModel
cag --new-view-model --name=Profile
```

##### New Feature Options
Manual page (optional):

```bash
--new-feature --name=<FeatureName> | --new-feature --name <FeatureName> | -nf --name=<FeatureName> | -nf --name <FeatureName>
Generate a new feature named <FeatureName>
--package=<PackageName> | --package <PackageName> | -p=<PackageName> | -p <PackageName> | -p<PackageName>
(Optional) Override the feature package for the preceding feature
# Generate man page (writes cli/build/man/cag.1)
./gradlew :cli:generateManPage

# Install to a man1 directory (may require sudo for system directories)
./gradlew :cli:installManPage

# Preview after install
man cag
```

##### New DataSource Options
```bash
--new-datasource --name=<DataSourceName> | --new-datasource --name <Name> | -nds --name=<Name> | -nds --name <Name>
Generate a new DataSource named <DataSourceName>DataSource
--with=ktor|retrofit|ktor,retrofit | -w=ktor|retrofit|ktor,retrofit
Attach dependencies to the preceding new data source
### CLI configuration (.cagrc)

You can configure library and plugin versions used by the CLI via a simple INI-style config file named `.cagrc`.

- Locations:
- Project root: `./.cagrc`
- User home: `~/.cagrc`

- Precedence:
- Values in the project `.cagrc` override values in `~/.cagrc`.

- Sections:
- `[new.versions]` — applied when generating new projects (e.g., `--new-project`).
- `[existing.versions]` — applied when generating into an existing project (e.g., new architecture, feature, data source, use case, or view model).

- Keys correspond to version keys used by the generator, for example: `kotlin`, `androidGradlePlugin`, `composeBom`, `composeNavigation`, `retrofit`, `ktor`, `okhttp3`, etc.

Example `~/.cagrc`:

```
[new.versions]
kotlin=2.2.10
composeBom=2025.08.01

##### New UseCase Options
```bash
--new-use-case --name=<UseCaseName> | --new-use-case --name <UseCaseName> | -nuc --name=<UseCaseName> | -nuc --name <UseCaseName>
Generate a new use case named <UseCaseName>UseCase.
--path=<TargetPath> | --path <TargetPath> | -p=<TargetPath> | -p <TargetPath> | -p<TargetPath>
(Optional) Specify the target directory for the preceding use case
By default, the target path is determined by the current location
--input-type=<InputType> | --input-type <InputType> | -it=<InputType> | -it <InputType> | -it<InputType>
(Optional) Specify the input data type for the preceding use case
By default, Unit is used
--output-type=<OutputType> | --output-type <OutputType> | -ot=<OutputType> | -ot <OutputType> | -ot<OutputType>
(Optional) Specify the output data type for the preceding use case
By default, Unit is used
[existing.versions]
retrofit=2.11.0
ktor=3.0.3
```

##### New ViewModel Options
```bash
--new-view-model --name=<ViewModelName> | --new-view-model --name <ViewModelName> | -nvm --name=<ViewModelName> | -nvm --name <ViewModelName>
Generate a new ViewModel named <ViewModelName>ViewModel.
--path=<TargetPath> | --path <TargetPath> | -p=<TargetPath> | -p <TargetPath> | -p<TargetPath>
(Optional) Specify the target directory for the preceding ViewModel
By default, the target path is determined by the current location
Example `./.cagrc` (project overrides):

```
[new.versions]
composeBom=2025.09.01

##### Other Options
```bash
--help, -h
Show the help document for cag
[existing.versions]
okhttp3=4.12.0
```

When run without arguments, the command prints a short usage and suggests using `--help` or `-h` for more options.
With the above, new projects will use `composeBom=2025.09.01` (from project), `kotlin=2.2.10` (from home). For operations on existing projects, `retrofit=2.11.0` (home) and `okhttp3=4.12.0` (project) will be applied.

### CLI configuration (.cagrc)

Expand Down
2 changes: 2 additions & 0 deletions cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,5 @@ ktlint {
include("**/*.kts")
}
}

apply(from = "man.gradle.kts")
196 changes: 196 additions & 0 deletions cli/man.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import org.gradle.api.DefaultTask
import org.gradle.api.file.ProjectLayout
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.ProviderFactory
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.bundling.Jar
import org.gradle.process.ExecOperations
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.file.Paths
import java.util.zip.GZIPOutputStream
import javax.inject.Inject

abstract class GenerateManPage
@Inject
constructor() : DefaultTask() {
@get:Inject
protected abstract val execOperations: ExecOperations

@get:Inject
protected abstract val layout: ProjectLayout

@get:OutputFile
abstract val outputFile: RegularFileProperty

@get:InputFile
abstract val shadowJarFile: RegularFileProperty

init {
description = "Generates a man page at build/man/cag.1"
group = "documentation"
outputFile.convention(layout.buildDirectory.file("man/cag.1"))
}

@TaskAction
fun generate() {
val manFile = outputFile.get().asFile
val manDir = manFile.parentFile
if (!manDir.exists()) manDir.mkdirs()
manFile.writeText("")

execOperations.exec {
commandLine(
"java",
"-jar",
shadowJarFile.get().asFile.absolutePath,
"--help",
"--format=man"
)
standardOutput = manFile.outputStream()
}
}
}

val shadowJar = tasks.named("shadowJar", Jar::class.java)
tasks.register("generateManPage", GenerateManPage::class.java) {
notCompatibleWithConfigurationCache("Exec-based stream redirection and script-defined task class can hold non-serializable references.")
dependsOn(shadowJar)
shadowJarFile.set(shadowJar.flatMap { it.archiveFile })
}

abstract class InstallManPage
@Inject
constructor() :
DefaultTask() {
@get:Inject
protected abstract val layout: ProjectLayout

@get:Inject
protected abstract val providers: ProviderFactory

@get:InputFile
abstract val sourceFileProperty: RegularFileProperty

@get:Input
abstract val manInstallDirectory: Property<String>

@get:OutputFile
abstract val outputGzipFile: RegularFileProperty

init {
description = "Installs the man page cag.1.gz to the man1 directory."
group = "documentation"

manInstallDirectory.convention(providers.gradleProperty("manInstallDir").orElse(defaultDirectory()))

outputGzipFile.convention(
manInstallDirectory.flatMap { dir ->
layout.file(providers.provider { File(dir).resolve("cag.1.gz") })
}
)
}

private fun defaultDirectory(): String {
val osName = System.getProperty("os.name").lowercase()
val homePath = System.getProperty("user.home")
val defaultDirectory =
if (osName.contains("mac") || osName.contains("darwin")) {
val brewPrefix = brewPrefix()
if (brewPrefix != null) {
Paths.get(brewPrefix, "share", "man", "man1").toString()
} else {
Paths.get(homePath, "Library", "Man", "man1").toString()
}
} else {
Paths.get(homePath, ".local", "share", "man", "man1").toString()
}
return defaultDirectory
}

@TaskAction
fun install() {
val sourceFile = sourceFileProperty.get().asFile
require(sourceFile.exists()) { "Man page not found at $sourceFile. Run :cli:generateManPage first." }

val chosenDirectory = manInstallDirectory.get()
val homePath = System.getProperty("user.home")
val resolvedDirectory =
if (chosenDirectory.startsWith("~/")) {
File(Paths.get(homePath, chosenDirectory.removePrefix("~/")).toString())
} else {
File(chosenDirectory)
}
val outputFile = File(resolvedDirectory, "cag.1.gz")
val outputDirectory = outputFile.parentFile

val osName = System.getProperty("os.name").lowercase()
if ((osName.contains("mac") || osName.contains("darwin")) && outputDirectory.absolutePath.startsWith("/usr/local/")) {
logger.warn(
"Target is under /usr/local; you may need elevated permissions (sudo) or " +
"choose a user directory like ~/Library/Man/man1"
)
}

if (!outputDirectory.exists()) {
check(outputDirectory.mkdirs()) { "Failed to create man directory: $outputDirectory" }
}

FileInputStream(sourceFile).use { fileInputStream ->
FileOutputStream(outputFile).use { fileOutputStream ->
GZIPOutputStream(fileOutputStream).use { gzipOutputStream ->
fileInputStream.copyTo(gzipOutputStream)
}
}
}

println("Installed man page: $outputFile")
if (osName.contains("linux")) {
println("You may need to update the man database (e.g., 'sudo mandb') on some systems.")
}

val sectionPattern = Regex("man[1-9]")
val manRootDirectory =
if (sectionPattern.matches(outputDirectory.name)) {
outputDirectory.parentFile ?: outputDirectory
} else {
outputDirectory
}
println("Preview with: man -M \"${manRootDirectory.absolutePath}\" cag")

val isMac = osName.let { it.contains("mac") || it.contains("darwin") }
if (isMac) {
val suggestedRoot = Paths.get(homePath, "Library", "Man").toString()
println("To make it permanent on zsh, add this line to ~/.zshrc then 'source ~/.zshrc':")
println(" export MANPATH=\"$suggestedRoot:$(manpath 2>/dev/null)\"")
val brewPrefix = brewPrefix()
if (brewPrefix != null) {
val brewMan1Path = Paths.get(brewPrefix, "share", "man", "man1").toString()
println("Alternatively, install system-wide (may require sudo):")
println(" ./gradlew :cli:installManPage -PmanInstallDir=$brewMan1Path")
}
}
}

private fun brewPrefix(): String? {
val brewEnv = System.getenv("HOMEBREW_PREFIX")
val prefix =
when {
!brewEnv.isNullOrBlank() -> brewEnv
File("/opt/homebrew").exists() -> "/opt/homebrew"
File("/usr/local").exists() -> "/usr/local"
else -> null
}
return prefix
}
}

tasks.register("installManPage", InstallManPage::class.java) {
dependsOn("generateManPage")
sourceFileProperty.set(layout.buildDirectory.file("man/cag.1"))
}
13 changes: 13 additions & 0 deletions cli/src/main/kotlin/com/mitteloupe/cag/cli/AppArgumentProcessor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ private val PRIMARY_FLAGS =
class AppArgumentProcessor(private val argumentParser: ArgumentParser = ArgumentParser()) {
fun isHelpRequested(arguments: Array<String>): Boolean = argumentParser.parsePrimaryWithSecondaries(arguments, HelpPrimary).isNotEmpty()

fun getHelpOptions(arguments: Array<String>): HelpOptions? {
val primaryFlagMatches = argumentParser.parsePrimaryWithSecondaries(arguments, HelpPrimary)
if (primaryFlagMatches.isEmpty()) {
return null
}
val secondaryFlags = primaryFlagMatches.first()
val topic = secondaryFlags[SecondaryFlagConstants.HELP_TOPIC]
val format = secondaryFlags[SecondaryFlagConstants.HELP_FORMAT]
return HelpOptions(topic = topic, format = format)
}

private fun getAllPrimaryFlagStrings(): Set<String> = PRIMARY_FLAGS.flatMap { listOf(it.long, it.short) }.toSet()

fun validateNoUnknownFlags(arguments: Array<String>) {
Expand Down Expand Up @@ -233,4 +244,6 @@ class AppArgumentProcessor(private val argumentParser: ArgumentParser = Argument
arguments[firstPrimaryIndex] == primaryFlag.long
}
}

data class HelpOptions(val topic: String?, val format: String?)
}
Loading