Skip to content
Merged
66 changes: 58 additions & 8 deletions packages/lib/src/core/templates-entrypoint/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,63 @@ const renderGeminiAuthConfig = (config: TemplateConfig): string =>
.replaceAll("__GEMINI_AUTH_ROOT__", geminiAuthRootContainerPath(config.sshUser))
.replaceAll("__GEMINI_HOME_DIR__", config.geminiHome)

const geminiSettingsJsonTemplate = `{
"model": {
"name": "gemini-3.1-pro-preview-yolo",
"compressionThreshold": 0.9,
"disableLoopDetection": true
},
"modelConfigs": {
"customAliases": {
"yolo-ultra": {
"modelConfig": {
"model": "gemini-3.1-pro-preview-yolo",
"generateContentConfig": {
"tools": [
{
"googleSearch": {}
},
{
"urlContext": {}
}
]
}
}
}
}
},
"general": {
"defaultApprovalMode": "auto_edit"
},
"tools": {
"allowed": [
"run_shell_command",
"write_file",
"googleSearch",
"urlContext"
]
},
"sandbox": {
"enabled": false
},
"security": {
"folderTrust": {
"enabled": false
},
"auth": {
"selectedType": "oauth-personal"
},
"disableYoloMode": false
},
"mcpServers": {
"playwright": {
"command": "docker-git-playwright-mcp",
"args": [],
"trust": true
}
}
}`

const renderGeminiPermissionSettingsConfig = (config: TemplateConfig): string =>
String.raw`# Gemini CLI: keep trust settings in sync with docker-git defaults
GEMINI_SETTINGS_DIR="${config.geminiHome}"
Expand All @@ -111,14 +168,7 @@ mkdir -p "$GEMINI_SETTINGS_DIR" || true
# Disable folder trust prompt and enable auto-approval in settings.json
if [[ ! -f "$GEMINI_CONFIG_SETTINGS_FILE" ]]; then
cat <<'EOF' > "$GEMINI_CONFIG_SETTINGS_FILE"
{
"security": {
"folderTrust": {
"enabled": false
},
"approvalPolicy": "never"
}
}
${geminiSettingsJsonTemplate}
EOF
fi

Expand Down
21 changes: 15 additions & 6 deletions packages/lib/src/usecases/actions/create-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { renderError } from "../errors.js"
import { applyGithubForkConfig } from "../github-fork.js"
import { defaultProjectsRoot } from "../menu-helpers.js"
import { findSshPrivateKey } from "../path-helpers.js"
import { buildSshCommand } from "../projects-core.js"
import { buildSshCommand, getContainerIpIfInsideContainer } from "../projects-core.js"
import { resolveTemplateResourceLimits } from "../resource-limits.js"
import { autoSyncState } from "../state-repo.js"
import { ensureTerminalCursorVisible } from "../terminal-cursor.js"
Expand Down Expand Up @@ -97,8 +97,11 @@ const isInteractiveTty = (): boolean => process.stdin.isTTY && process.stdout.is
const buildSshArgs = (
config: CreateCommand["config"],
sshKeyPath: string | null,
remoteCommand?: string
remoteCommand?: string,
ipAddress?: string
): ReadonlyArray<string> => {
const host = ipAddress ?? "localhost"
const port = ipAddress ? 22 : config.sshPort
const args: Array<string> = []
if (sshKeyPath !== null) {
args.push("-i", sshKeyPath)
Expand All @@ -113,8 +116,8 @@ const buildSshArgs = (
"-o",
"UserKnownHostsFile=/dev/null",
"-p",
String(config.sshPort),
`${config.sshUser}@localhost`
String(port),
`${config.sshUser}@${host}`
)
if (remoteCommand !== undefined) {
args.push(remoteCommand)
Expand All @@ -140,8 +143,14 @@ const openSshBestEffort = (
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)

const ipAddress = yield* _(
getContainerIpIfInsideContainer(fs, process.cwd(), template.containerName).pipe(
Effect.orElse(() => Effect.succeed<string | undefined>(""))
)
)

const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd()))
const sshCommand = buildSshCommand(template, sshKey)
const sshCommand = buildSshCommand(template, sshKey, ipAddress)

const remoteCommandLabel = remoteCommand === undefined ? "" : ` (${remoteCommand})`

Expand All @@ -152,7 +161,7 @@ const openSshBestEffort = (
{
cwd: process.cwd(),
command: "ssh",
args: buildSshArgs(template, sshKey, remoteCommand)
args: buildSshArgs(template, sshKey, remoteCommand, ipAddress)
},
[0, 130],
(exitCode) => new CommandFailedError({ command: "ssh", exitCode })
Expand Down
20 changes: 18 additions & 2 deletions packages/lib/src/usecases/actions/docker-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
runDockerComposeUpRecreate,
runDockerExecExitCode,
runDockerInspectContainerBridgeIp,
runDockerInspectContainerIp,
runDockerNetworkConnectBridge
} from "../../shell/docker.js"
import type { DockerCommandError } from "../../shell/errors.js"
Expand All @@ -31,14 +32,29 @@ const agentFailPath = "/run/docker-git/agent.failed"
const logSshAccess = (
baseDir: string,
config: CreateCommand["config"]
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor> =>
Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)

const isInsideContainer = yield* _(fs.exists("/.dockerenv"))
let ipAddress: string | undefined

if (isInsideContainer) {
const containerIp = yield* _(
runDockerInspectContainerIp(baseDir, config.containerName).pipe(
Effect.orElse(() => Effect.succeed(""))
)
)
if (containerIp.length > 0) {
ipAddress = containerIp
}
}

const resolvedAuthorizedKeys = resolveAuthorizedKeysPath(path, baseDir, config.authorizedKeysPath)
const authExists = yield* _(fs.exists(resolvedAuthorizedKeys))
const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd()))
const sshCommand = buildSshCommand(config, sshKey)
const sshCommand = buildSshCommand(config, sshKey, ipAddress)

yield* _(Effect.log(`SSH access: ${sshCommand}`))
if (!authExists) {
Expand Down
81 changes: 58 additions & 23 deletions packages/lib/src/usecases/auth-gemini-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,35 +237,70 @@ export const prepareGeminiCredentialsDir = (
return credentialsDir
})

export const defaultGeminiSettings = {
model: {
name: "gemini-3.1-pro-preview-yolo",
compressionThreshold: 0.9,
disableLoopDetection: true
},
modelConfigs: {
customAliases: {
"yolo-ultra": {
"modelConfig": {
"model": "gemini-3.1-pro-preview-yolo",
"generateContentConfig": {
"tools": [
{
"googleSearch": {}
},
{
"urlContext": {}
}
]
}
}
}
}
},
general: {
defaultApprovalMode: "auto_edit"
},
tools: {
allowed: [
"run_shell_command",
"write_file",
"googleSearch",
"urlContext"
]
},
sandbox: {
enabled: false
},
security: {
folderTrust: {
enabled: false
},
auth: {
selectedType: "oauth-personal"
},
disableYoloMode: false
},
mcpServers: {
playwright: {
command: "docker-git-playwright-mcp",
args: [],
trust: true
}
}
}

export const writeInitialSettings = (credentialsDir: string, fs: FileSystem.FileSystem) =>
Effect.gen(function*(_) {
const settingsPath = `${credentialsDir}/settings.json`
yield* _(
fs.writeFileString(
settingsPath,
JSON.stringify(
{
model: {
name: "gemini-2.0-flash",
compressionThreshold: 0.9,
disableLoopDetection: true
},
general: {
defaultApprovalMode: "auto_edit"
},
yolo: true,
sandbox: {
enabled: false
},
security: {
folderTrust: { enabled: false },
auth: { selectedType: "oauth-personal" },
approvalPolicy: "never"
}
},
null,
2
)
JSON.stringify(defaultGeminiSettings, null, 2) + "\n"
)
)

Expand Down
37 changes: 37 additions & 0 deletions packages/lib/src/usecases/auth-gemini-logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { PlatformError } from "@effect/platform/Error"
import { Effect } from "effect"

import type { AuthGeminiLogoutCommand } from "../core/domain.js"
import type { CommandFailedError } from "../shell/errors.js"
import { geminiApiKeyPath, geminiCredentialsPath, geminiEnvFilePath, withGeminiAuth } from "./auth-gemini-helpers.js"
import type { GeminiRuntime } from "./auth-gemini-helpers.js"
import { normalizeAccountLabel } from "./auth-helpers.js"
import { autoSyncState } from "./state-repo.js"

// CHANGE: logout Gemini CLI by clearing API key and OAuth credentials for a label
// WHY: allow revoking Gemini CLI access deterministically
// QUOTE(ТЗ): "Добавь поддержку gemini CLI"
// REF: issue-146
// SOURCE: https://geminicli.com/docs/get-started/authentication/
// FORMAT THEOREM: forall cmd: authGeminiLogout(cmd) -> credentials_cleared(cmd)
// PURITY: SHELL
// EFFECT: Effect<void, PlatformError | CommandFailedError, GeminiRuntime>
// INVARIANT: all credential files (API key and OAuth) are removed from account directory
// COMPLEXITY: O(1)
export const authGeminiLogout = (
command: AuthGeminiLogoutCommand
): Effect.Effect<void, PlatformError | CommandFailedError, GeminiRuntime> =>
Effect.gen(function*(_) {
const accountLabel = normalizeAccountLabel(command.label, "default")
yield* _(
withGeminiAuth(command, ({ accountPath, fs }) =>
Effect.gen(function*(_) {
// Clear API key
yield* _(fs.remove(geminiApiKeyPath(accountPath), { force: true }))
yield* _(fs.remove(geminiEnvFilePath(accountPath), { force: true }))
// Clear OAuth credentials (entire .gemini directory)
yield* _(fs.remove(geminiCredentialsPath(accountPath), { recursive: true, force: true }))
}))
)
yield* _(autoSyncState(`chore(state): auth gemini logout ${accountLabel}`))
}).pipe(Effect.asVoid)
30 changes: 30 additions & 0 deletions packages/lib/src/usecases/auth-gemini-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { PlatformError } from "@effect/platform/Error"
import { Effect } from "effect"

import type { AuthGeminiStatusCommand } from "../core/domain.js"
import type { CommandFailedError } from "../shell/errors.js"
import { resolveGeminiAuthMethod, withGeminiAuth } from "./auth-gemini-helpers.js"
import type { GeminiRuntime } from "./auth-gemini-helpers.js"

// CHANGE: show Gemini CLI auth status for a given label
// WHY: allow verifying API key/OAuth presence without exposing credentials
// QUOTE(ТЗ): "Добавь поддержку gemini CLI"
// REF: issue-146
// SOURCE: https://geminicli.com/docs/get-started/authentication/
// FORMAT THEOREM: forall cmd: authGeminiStatus(cmd) -> connected(cmd, method) | disconnected(cmd)
// PURITY: SHELL
// EFFECT: Effect<void, PlatformError | CommandFailedError, GeminiRuntime>
// INVARIANT: never logs API keys or OAuth tokens
// COMPLEXITY: O(1)
export const authGeminiStatus = (
command: AuthGeminiStatusCommand
): Effect.Effect<void, PlatformError | CommandFailedError, GeminiRuntime> =>
withGeminiAuth(command, ({ accountLabel, accountPath, fs }) =>
Effect.gen(function*(_) {
const authMethod = yield* _(resolveGeminiAuthMethod(fs, accountPath))
if (authMethod === "none") {
yield* _(Effect.log(`Gemini not connected (${accountLabel}).`))
return
}
yield* _(Effect.log(`Gemini connected (${accountLabel}, ${authMethod}).`))
}))
Loading
Loading