Skip to content
Open
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 .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,14 @@ if [ "${DOCKER_GIT_SKIP_KNOWLEDGE_GUARD:-}" = "1" ]; then
fi

node scripts/pre-push-knowledge-guard.js "$@"

# CHANGE: backup AI session to a private session repository on push (supports Claude, Codex, Gemini)
# WHY: allows returning to old AI sessions and provides PR context without gist limits
# QUOTE(ТЗ): "когда происходит push мы сразу заливаем текущую сессию с AI агентом в gits приватный"
# REF: issue-143
# PURITY: SHELL
if [ "${DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then
if command -v gh >/dev/null 2>&1; then
node scripts/session-backup-gist.js --verbose || echo "[session-backup] Warning: session backup failed (non-fatal)"
fi
fi
3 changes: 2 additions & 1 deletion packages/app/.jscpd.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
],
"skipComments": true,
"ignorePattern": [
"private readonly \\w+: \\w+;\\s*private readonly \\w+: \\w+;\\s*private \\w+: \\w+ \\| null = null;\\s*private \\w+: \\w+ \\| null = null;"
"private readonly \\w+: \\w+;\\s*private readonly \\w+: \\w+;\\s*private \\w+: \\w+ \\| null = null;\\s*private \\w+: \\w+ \\| null = null;",
"const \\{ rest, subcommand \\} = splitSubcommand\\(args\\)\\s*if \\(subcommand === null\\) \\{\\s*return parseList\\(args\\)\\s*\\}\\s*return Match\\.value\\(subcommand\\)\\.pipe\\("
]
}
20 changes: 17 additions & 3 deletions packages/app/src/docker-git/cli/parser-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ interface ValueOptionSpec {
| "projectDir"
| "lines"
| "agentAutoMode"
| "prNumber"
| "repo"
| "limit"
| "output"
}

const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
Expand Down Expand Up @@ -75,7 +79,12 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
{ flag: "--out-dir", key: "outDir" },
{ flag: "--project-dir", key: "projectDir" },
{ flag: "--lines", key: "lines" },
{ flag: "--auto", key: "agentAutoMode" }
{ flag: "--auto", key: "agentAutoMode" },
{ flag: "--pr-number", key: "prNumber" },
{ flag: "--pr", key: "prNumber" },
{ flag: "--repo", key: "repo" },
{ flag: "--limit", key: "limit" },
{ flag: "--output", key: "output" }
]

const valueOptionSpecByFlag: ReadonlyMap<string, ValueOptionSpec> = new Map(
Expand All @@ -97,7 +106,8 @@ const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => RawOptio
"--no-wipe": (raw) => ({ ...raw, wipe: false }),
"--web": (raw) => ({ ...raw, authWeb: true }),
"--include-default": (raw) => ({ ...raw, includeDefault: true }),
"--auto": (raw) => ({ ...raw, agentAutoMode: "auto" })
"--auto": (raw) => ({ ...raw, agentAutoMode: "auto" }),
"--no-comment": (raw) => ({ ...raw, noComment: true })
}

const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: string) => RawOptions } = {
Expand Down Expand Up @@ -131,7 +141,11 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
outDir: (raw, value) => ({ ...raw, outDir: value }),
projectDir: (raw, value) => ({ ...raw, projectDir: value }),
lines: (raw, value) => ({ ...raw, lines: value }),
agentAutoMode: (raw, value) => ({ ...raw, agentAutoMode: value.trim().toLowerCase() })
agentAutoMode: (raw, value) => ({ ...raw, agentAutoMode: value.trim().toLowerCase() }),
prNumber: (raw, value) => ({ ...raw, prNumber: value }),
repo: (raw, value) => ({ ...raw, repo: value }),
limit: (raw, value) => ({ ...raw, limit: value }),
output: (raw, value) => ({ ...raw, output: value })
}

export const applyCommandBooleanFlag = (raw: RawOptions, token: string): RawOptions | null => {
Expand Down
103 changes: 103 additions & 0 deletions packages/app/src/docker-git/cli/parser-session-gists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Either, Match } from "effect"

import {
type ParseError,
type SessionGistBackupCommand,
type SessionGistCommand,
type SessionGistDownloadCommand,
type SessionGistListCommand,
type SessionGistViewCommand
} from "@effect-template/lib/core/domain"

import { parsePositiveInt, parseProjectDirWithOptions, splitSubcommand } from "./parser-shared.js"

// CHANGE: parse session backup commands for backup/list/view/download
// WHY: enables CLI access to session backup repository functionality
// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами"
// REF: issue-143
// PURITY: CORE
// EFFECT: Either<SessionGistCommand, ParseError>
// INVARIANT: all subcommands are deterministically parsed
// COMPLEXITY: O(n) where n = |args|

const defaultLimit = 20
const defaultOutputDir = "./.session-restore"

const missingSnapshotRefError: ParseError = { _tag: "MissingRequiredOption", option: "snapshot-ref" }

const extractSnapshotRef = (args: ReadonlyArray<string>): string | null => {
const snapshotRef = args[0]
return snapshotRef && !snapshotRef.startsWith("-") ? snapshotRef : null
}

const parseBackup = (
args: ReadonlyArray<string>
): Either.Either<SessionGistBackupCommand, ParseError> =>
Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({
_tag: "SessionGistBackup",
projectDir,
prNumber: raw.prNumber ? Number.parseInt(raw.prNumber, 10) : null,
repo: raw.repo ?? null,
postComment: raw.noComment !== true
}))

const parseList = (
args: ReadonlyArray<string>
): Either.Either<SessionGistListCommand, ParseError> =>
Either.gen(function*(_) {
const { raw } = yield* _(parseProjectDirWithOptions(args))
const limit = raw.limit
? yield* _(parsePositiveInt("--limit", raw.limit))
: defaultLimit
return {
_tag: "SessionGistList",
limit,
repo: raw.repo ?? null
}
})

const parseView = (
args: ReadonlyArray<string>
): Either.Either<SessionGistViewCommand, ParseError> => {
const snapshotRef = extractSnapshotRef(args)
return snapshotRef
? Either.right({ _tag: "SessionGistView", snapshotRef })
: Either.left(missingSnapshotRefError)
}

const parseDownload = (
args: ReadonlyArray<string>
): Either.Either<SessionGistDownloadCommand, ParseError> => {
const snapshotRef = extractSnapshotRef(args)
if (!snapshotRef) {
return Either.left(missingSnapshotRefError)
}
return Either.map(parseProjectDirWithOptions(args.slice(1)), ({ raw }) => ({
_tag: "SessionGistDownload",
snapshotRef,
outputDir: raw.output ?? defaultOutputDir
}))
}

const unknownActionError = (action: string): ParseError => ({
_tag: "InvalidOption",
option: "session-gists",
reason: `unknown action ${action}`
})

export const parseSessionGists = (
args: ReadonlyArray<string>
): Either.Either<SessionGistCommand, ParseError> => {
const { rest, subcommand } = splitSubcommand(args)
if (subcommand === null) {
return parseList(args)
}

return Match.value(subcommand).pipe(
Match.when("backup", () => parseBackup(rest)),
Match.when("list", () => parseList(rest)),
Match.when("view", () => parseView(rest)),
Match.when("download", () => parseDownload(rest)),
Match.orElse(() => Either.left(unknownActionError(subcommand)))
)
}
31 changes: 5 additions & 26 deletions packages/app/src/docker-git/cli/parser-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,10 @@ import { Either, Match } from "effect"

import { type ParseError, type SessionsCommand } from "@effect-template/lib/core/domain"

import { parseProjectDirWithOptions } from "./parser-shared.js"
import { parsePositiveInt, parseProjectDirWithOptions, splitSubcommand } from "./parser-shared.js"

const defaultLines = 200

const parsePositiveInt = (
option: string,
raw: string
): Either.Either<number, ParseError> => {
const value = Number.parseInt(raw, 10)
if (!Number.isFinite(value) || value <= 0) {
const error: ParseError = {
_tag: "InvalidOption",
option,
reason: "expected positive integer"
}
return Either.left(error)
}
return Either.right(value)
}

const parseList = (args: ReadonlyArray<string>): Either.Either<SessionsCommand, ParseError> =>
Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({
_tag: "SessionsList",
Expand Down Expand Up @@ -73,17 +57,12 @@ const parseLogs = (args: ReadonlyArray<string>): Either.Either<SessionsCommand,
export const parseSessions = (
args: ReadonlyArray<string>
): Either.Either<SessionsCommand, ParseError> => {
if (args.length === 0) {
return parseList(args)
}

const first = args[0] ?? ""
if (first.startsWith("-")) {
const { rest, subcommand } = splitSubcommand(args)
if (subcommand === null) {
return parseList(args)
}

const rest = args.slice(1)
return Match.value(first).pipe(
return Match.value(subcommand).pipe(
Match.when("list", () => parseList(rest)),
Match.when("kill", () => parseKill(rest)),
Match.when("stop", () => parseKill(rest)),
Expand All @@ -93,7 +72,7 @@ export const parseSessions = (
const error: ParseError = {
_tag: "InvalidOption",
option: "sessions",
reason: `unknown action ${first}`
reason: `unknown action ${subcommand}`
}
return Either.left(error)
})
Expand Down
42 changes: 42 additions & 0 deletions packages/app/src/docker-git/cli/parser-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,45 @@ export const parseProjectDirArgs = (
parseProjectDirWithOptions(args, defaultProjectDir),
({ projectDir }) => ({ projectDir })
)

// CHANGE: extract shared positive integer parser
// WHY: avoid code duplication across session parsers
// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами"
// REF: issue-143
// PURITY: CORE
// EFFECT: Either<number, ParseError>
// INVARIANT: returns error for non-positive integers
// COMPLEXITY: O(1)
export const parsePositiveInt = (
option: string,
raw: string
): Either.Either<number, ParseError> => {
const value = Number.parseInt(raw, 10)
if (!Number.isFinite(value) || value <= 0) {
const error: ParseError = {
_tag: "InvalidOption",
option,
reason: "expected positive integer"
}
return Either.left(error)
}
return Either.right(value)
}

// CHANGE: shared helper to extract first arg and rest for subcommand parsing
// WHY: avoid code duplication in parser-sessions and parser-session-gists
// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами"
// REF: issue-143
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: returns null subcommand if first arg starts with dash or is empty
// COMPLEXITY: O(1)
export const splitSubcommand = (
args: ReadonlyArray<string>
): { readonly subcommand: string | null; readonly rest: ReadonlyArray<string> } => {
const first = args[0]
if (!first || first.startsWith("-")) {
return { subcommand: null, rest: args }
}
return { subcommand: first, rest: args.slice(1) }
}
7 changes: 5 additions & 2 deletions packages/app/src/docker-git/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { parseMcpPlaywright } from "./parser-mcp-playwright.js"
import { parseRawOptions } from "./parser-options.js"
import { parsePanes } from "./parser-panes.js"
import { parseScrap } from "./parser-scrap.js"
import { parseSessionGists } from "./parser-session-gists.js"
import { parseSessions } from "./parser-sessions.js"
import { parseState } from "./parser-state.js"
import { usageText } from "./usage.js"
Expand Down Expand Up @@ -71,13 +72,15 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
Match.when("stop-all", () => Either.right(downAllCommand)),
Match.when("kill-all", () => Either.right(downAllCommand)),
Match.when("menu", () => Either.right(menuCommand)),
Match.when("ui", () => Either.right(menuCommand)),
Match.when("auth", () => parseAuth(rest))
Match.when("ui", () => Either.right(menuCommand))
)
.pipe(
Match.when("auth", () => parseAuth(rest)),
Match.when("open", () => parseAttach(rest)),
Match.when("apply", () => parseApply(rest)),
Match.when("state", () => parseState(rest)),
Match.when("session-gists", () => parseSessionGists(rest)),
Match.when("gists", () => parseSessionGists(rest)),
Match.orElse(() => Either.left(unknownCommandError))
)
}
10 changes: 10 additions & 0 deletions packages/app/src/docker-git/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ docker-git scrap <action> [<url>] [options]
docker-git sessions [list] [<url>] [options]
docker-git sessions kill <pid> [<url>] [options]
docker-git sessions logs <pid> [<url>] [options]
docker-git session-gists [list] [options]
docker-git session-gists backup [<url>] [options]
docker-git session-gists view <snapshot-ref>
docker-git session-gists download <snapshot-ref> [options]
docker-git ps
docker-git down-all
docker-git auth <provider> <action> [options]
Expand All @@ -30,6 +34,7 @@ Commands:
panes, terms List tmux panes for a docker-git project
scrap Export/import project scrap (session snapshot + rebuildable deps)
sessions List/kill/log container terminal processes
session-gists Manage AI session backups via a private session repository (backup/list/view/download)
ps, status Show docker compose status for all docker-git projects
down-all Stop all docker-git containers (docker compose down)
auth Manage GitHub/Codex/Claude Code auth for docker-git
Expand Down Expand Up @@ -64,6 +69,11 @@ Options:
--wipe | --no-wipe Wipe workspace before scrap import (default: --wipe)
--lines <n> Tail last N lines for sessions logs (default: 200)
--include-default Show default/system processes in sessions list
--pr-number <n> PR number for session backup comment
--repo <owner/repo> Repository for session backup operations
--limit <n> Limit for session backup snapshot list (default: 20)
--output <path> Output directory for session backup download (default: ./.session-restore)
--no-comment Skip posting PR comment after session backup
--up | --no-up Run docker compose up after init (default: --up)
--ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh)
--mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright)
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/docker-git/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import { renderError } from "@effect-template/lib/usecases/errors"
import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright"
import { downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects"
import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap"
import {
sessionGistBackup,
sessionGistDownload,
sessionGistList,
sessionGistView
} from "@effect-template/lib/usecases/session-gists"
import {
stateCommit,
stateInit,
Expand Down Expand Up @@ -110,6 +116,10 @@ const handleNonBaseCommand = (command: NonBaseCommand) =>
Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)),
Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)),
Match.when({ _tag: "McpPlaywrightUp" }, (cmd) => mcpPlaywrightUp(cmd)),
Match.when({ _tag: "SessionGistBackup" }, (cmd) => sessionGistBackup(cmd)),
Match.when({ _tag: "SessionGistList" }, (cmd) => sessionGistList(cmd)),
Match.when({ _tag: "SessionGistView" }, (cmd) => sessionGistView(cmd)),
Match.when({ _tag: "SessionGistDownload" }, (cmd) => sessionGistDownload(cmd)),
Match.exhaustive
)

Expand Down
6 changes: 6 additions & 0 deletions packages/lib/src/core/command-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export interface RawOptions {
readonly force?: boolean
readonly forceEnv?: boolean
readonly agentAutoMode?: string
// Session gist options (issue-143)
readonly prNumber?: string
readonly repo?: string
readonly noComment?: boolean
readonly limit?: string
readonly output?: string
}

// CHANGE: helper type alias for builder signatures that produce parse errors
Expand Down
10 changes: 10 additions & 0 deletions packages/lib/src/core/domain.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { SessionGistCommand } from "./session-gist-domain.js"

export type { MenuAction, ParseError } from "./menu.js"
export { parseMenuSelection } from "./menu.js"
export { deriveRepoPathParts, deriveRepoSlug, resolveRepoInput } from "./repo.js"
Expand Down Expand Up @@ -271,10 +273,18 @@ export interface AuthGeminiLogoutCommand {
readonly geminiAuthPath: string
}

export type {
SessionGistBackupCommand,
SessionGistCommand,
SessionGistDownloadCommand,
SessionGistListCommand,
SessionGistViewCommand
} from "./session-gist-domain.js"
export type SessionsCommand =
| SessionsListCommand
| SessionsKillCommand
| SessionsLogsCommand
| SessionGistCommand

export type ScrapCommand =
| ScrapExportCommand
Expand Down
Loading
Loading