Skip to content

Commit bc88d0a

Browse files
authored
Merge pull request #145 from konard/issue-143-8b42b7a3b21f
feat(session-gists): auto-backup AI sessions (Claude, Codex, Gemini) to private gists on push
2 parents 898ce14 + 25caef9 commit bc88d0a

20 files changed

Lines changed: 2102 additions & 45 deletions

.githooks/pre-push

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,14 @@ if [ "${DOCKER_GIT_SKIP_KNOWLEDGE_GUARD:-}" = "1" ]; then
1010
fi
1111

1212
node scripts/pre-push-knowledge-guard.js "$@"
13+
14+
# CHANGE: backup AI session to a private session repository on push (supports Claude, Codex, Gemini)
15+
# WHY: allows returning to old AI sessions and provides PR context without gist limits
16+
# QUOTE(ТЗ): "когда происходит push мы сразу заливаем текущую сессию с AI агентом в gits приватный"
17+
# REF: issue-143
18+
# PURITY: SHELL
19+
if [ "${DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then
20+
if command -v gh >/dev/null 2>&1; then
21+
node scripts/session-backup-gist.js --verbose || echo "[session-backup] Warning: session backup failed (non-fatal)"
22+
fi
23+
fi

packages/app/.jscpd.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
],
1212
"skipComments": true,
1313
"ignorePattern": [
14-
"private readonly \\w+: \\w+;\\s*private readonly \\w+: \\w+;\\s*private \\w+: \\w+ \\| null = null;\\s*private \\w+: \\w+ \\| null = null;"
14+
"private readonly \\w+: \\w+;\\s*private readonly \\w+: \\w+;\\s*private \\w+: \\w+ \\| null = null;\\s*private \\w+: \\w+ \\| null = null;",
15+
"const \\{ rest, subcommand \\} = splitSubcommand\\(args\\)\\s*if \\(subcommand === null\\) \\{\\s*return parseList\\(args\\)\\s*\\}\\s*return Match\\.value\\(subcommand\\)\\.pipe\\("
1516
]
1617
}

packages/app/src/docker-git/cli/parser-options.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ interface ValueOptionSpec {
3737
| "projectDir"
3838
| "lines"
3939
| "agentAutoMode"
40+
| "prNumber"
41+
| "repo"
42+
| "limit"
43+
| "output"
4044
}
4145

4246
const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
@@ -75,7 +79,12 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
7579
{ flag: "--out-dir", key: "outDir" },
7680
{ flag: "--project-dir", key: "projectDir" },
7781
{ flag: "--lines", key: "lines" },
78-
{ flag: "--auto", key: "agentAutoMode" }
82+
{ flag: "--auto", key: "agentAutoMode" },
83+
{ flag: "--pr-number", key: "prNumber" },
84+
{ flag: "--pr", key: "prNumber" },
85+
{ flag: "--repo", key: "repo" },
86+
{ flag: "--limit", key: "limit" },
87+
{ flag: "--output", key: "output" }
7988
]
8089

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

103113
const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: string) => RawOptions } = {
@@ -131,7 +141,11 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
131141
outDir: (raw, value) => ({ ...raw, outDir: value }),
132142
projectDir: (raw, value) => ({ ...raw, projectDir: value }),
133143
lines: (raw, value) => ({ ...raw, lines: value }),
134-
agentAutoMode: (raw, value) => ({ ...raw, agentAutoMode: value.trim().toLowerCase() })
144+
agentAutoMode: (raw, value) => ({ ...raw, agentAutoMode: value.trim().toLowerCase() }),
145+
prNumber: (raw, value) => ({ ...raw, prNumber: value }),
146+
repo: (raw, value) => ({ ...raw, repo: value }),
147+
limit: (raw, value) => ({ ...raw, limit: value }),
148+
output: (raw, value) => ({ ...raw, output: value })
135149
}
136150

137151
export const applyCommandBooleanFlag = (raw: RawOptions, token: string): RawOptions | null => {
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Either, Match } from "effect"
2+
3+
import {
4+
type ParseError,
5+
type SessionGistBackupCommand,
6+
type SessionGistCommand,
7+
type SessionGistDownloadCommand,
8+
type SessionGistListCommand,
9+
type SessionGistViewCommand
10+
} from "@effect-template/lib/core/domain"
11+
12+
import { parsePositiveInt, parseProjectDirWithOptions, splitSubcommand } from "./parser-shared.js"
13+
14+
// CHANGE: parse session backup commands for backup/list/view/download
15+
// WHY: enables CLI access to session backup repository functionality
16+
// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами"
17+
// REF: issue-143
18+
// PURITY: CORE
19+
// EFFECT: Either<SessionGistCommand, ParseError>
20+
// INVARIANT: all subcommands are deterministically parsed
21+
// COMPLEXITY: O(n) where n = |args|
22+
23+
const defaultLimit = 20
24+
const defaultOutputDir = "./.session-restore"
25+
26+
const missingSnapshotRefError: ParseError = { _tag: "MissingRequiredOption", option: "snapshot-ref" }
27+
28+
const extractSnapshotRef = (args: ReadonlyArray<string>): string | null => {
29+
const snapshotRef = args[0]
30+
return snapshotRef && !snapshotRef.startsWith("-") ? snapshotRef : null
31+
}
32+
33+
const parseBackup = (
34+
args: ReadonlyArray<string>
35+
): Either.Either<SessionGistBackupCommand, ParseError> =>
36+
Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({
37+
_tag: "SessionGistBackup",
38+
projectDir,
39+
prNumber: raw.prNumber ? Number.parseInt(raw.prNumber, 10) : null,
40+
repo: raw.repo ?? null,
41+
postComment: raw.noComment !== true
42+
}))
43+
44+
const parseList = (
45+
args: ReadonlyArray<string>
46+
): Either.Either<SessionGistListCommand, ParseError> =>
47+
Either.gen(function*(_) {
48+
const { raw } = yield* _(parseProjectDirWithOptions(args))
49+
const limit = raw.limit
50+
? yield* _(parsePositiveInt("--limit", raw.limit))
51+
: defaultLimit
52+
return {
53+
_tag: "SessionGistList",
54+
limit,
55+
repo: raw.repo ?? null
56+
}
57+
})
58+
59+
const parseView = (
60+
args: ReadonlyArray<string>
61+
): Either.Either<SessionGistViewCommand, ParseError> => {
62+
const snapshotRef = extractSnapshotRef(args)
63+
return snapshotRef
64+
? Either.right({ _tag: "SessionGistView", snapshotRef })
65+
: Either.left(missingSnapshotRefError)
66+
}
67+
68+
const parseDownload = (
69+
args: ReadonlyArray<string>
70+
): Either.Either<SessionGistDownloadCommand, ParseError> => {
71+
const snapshotRef = extractSnapshotRef(args)
72+
if (!snapshotRef) {
73+
return Either.left(missingSnapshotRefError)
74+
}
75+
return Either.map(parseProjectDirWithOptions(args.slice(1)), ({ raw }) => ({
76+
_tag: "SessionGistDownload",
77+
snapshotRef,
78+
outputDir: raw.output ?? defaultOutputDir
79+
}))
80+
}
81+
82+
const unknownActionError = (action: string): ParseError => ({
83+
_tag: "InvalidOption",
84+
option: "session-gists",
85+
reason: `unknown action ${action}`
86+
})
87+
88+
export const parseSessionGists = (
89+
args: ReadonlyArray<string>
90+
): Either.Either<SessionGistCommand, ParseError> => {
91+
const { rest, subcommand } = splitSubcommand(args)
92+
if (subcommand === null) {
93+
return parseList(args)
94+
}
95+
96+
return Match.value(subcommand).pipe(
97+
Match.when("backup", () => parseBackup(rest)),
98+
Match.when("list", () => parseList(rest)),
99+
Match.when("view", () => parseView(rest)),
100+
Match.when("download", () => parseDownload(rest)),
101+
Match.orElse(() => Either.left(unknownActionError(subcommand)))
102+
)
103+
}

packages/app/src/docker-git/cli/parser-sessions.ts

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,10 @@ import { Either, Match } from "effect"
22

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

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

77
const defaultLines = 200
88

9-
const parsePositiveInt = (
10-
option: string,
11-
raw: string
12-
): Either.Either<number, ParseError> => {
13-
const value = Number.parseInt(raw, 10)
14-
if (!Number.isFinite(value) || value <= 0) {
15-
const error: ParseError = {
16-
_tag: "InvalidOption",
17-
option,
18-
reason: "expected positive integer"
19-
}
20-
return Either.left(error)
21-
}
22-
return Either.right(value)
23-
}
24-
259
const parseList = (args: ReadonlyArray<string>): Either.Either<SessionsCommand, ParseError> =>
2610
Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({
2711
_tag: "SessionsList",
@@ -73,17 +57,12 @@ const parseLogs = (args: ReadonlyArray<string>): Either.Either<SessionsCommand,
7357
export const parseSessions = (
7458
args: ReadonlyArray<string>
7559
): Either.Either<SessionsCommand, ParseError> => {
76-
if (args.length === 0) {
77-
return parseList(args)
78-
}
79-
80-
const first = args[0] ?? ""
81-
if (first.startsWith("-")) {
60+
const { rest, subcommand } = splitSubcommand(args)
61+
if (subcommand === null) {
8262
return parseList(args)
8363
}
8464

85-
const rest = args.slice(1)
86-
return Match.value(first).pipe(
65+
return Match.value(subcommand).pipe(
8766
Match.when("list", () => parseList(rest)),
8867
Match.when("kill", () => parseKill(rest)),
8968
Match.when("stop", () => parseKill(rest)),
@@ -93,7 +72,7 @@ export const parseSessions = (
9372
const error: ParseError = {
9473
_tag: "InvalidOption",
9574
option: "sessions",
96-
reason: `unknown action ${first}`
75+
reason: `unknown action ${subcommand}`
9776
}
9877
return Either.left(error)
9978
})

packages/app/src/docker-git/cli/parser-shared.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,45 @@ export const parseProjectDirArgs = (
4949
parseProjectDirWithOptions(args, defaultProjectDir),
5050
({ projectDir }) => ({ projectDir })
5151
)
52+
53+
// CHANGE: extract shared positive integer parser
54+
// WHY: avoid code duplication across session parsers
55+
// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами"
56+
// REF: issue-143
57+
// PURITY: CORE
58+
// EFFECT: Either<number, ParseError>
59+
// INVARIANT: returns error for non-positive integers
60+
// COMPLEXITY: O(1)
61+
export const parsePositiveInt = (
62+
option: string,
63+
raw: string
64+
): Either.Either<number, ParseError> => {
65+
const value = Number.parseInt(raw, 10)
66+
if (!Number.isFinite(value) || value <= 0) {
67+
const error: ParseError = {
68+
_tag: "InvalidOption",
69+
option,
70+
reason: "expected positive integer"
71+
}
72+
return Either.left(error)
73+
}
74+
return Either.right(value)
75+
}
76+
77+
// CHANGE: shared helper to extract first arg and rest for subcommand parsing
78+
// WHY: avoid code duplication in parser-sessions and parser-session-gists
79+
// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами"
80+
// REF: issue-143
81+
// PURITY: CORE
82+
// EFFECT: n/a
83+
// INVARIANT: returns null subcommand if first arg starts with dash or is empty
84+
// COMPLEXITY: O(1)
85+
export const splitSubcommand = (
86+
args: ReadonlyArray<string>
87+
): { readonly subcommand: string | null; readonly rest: ReadonlyArray<string> } => {
88+
const first = args[0]
89+
if (!first || first.startsWith("-")) {
90+
return { subcommand: null, rest: args }
91+
}
92+
return { subcommand: first, rest: args.slice(1) }
93+
}

packages/app/src/docker-git/cli/parser.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { parseMcpPlaywright } from "./parser-mcp-playwright.js"
1111
import { parseRawOptions } from "./parser-options.js"
1212
import { parsePanes } from "./parser-panes.js"
1313
import { parseScrap } from "./parser-scrap.js"
14+
import { parseSessionGists } from "./parser-session-gists.js"
1415
import { parseSessions } from "./parser-sessions.js"
1516
import { parseState } from "./parser-state.js"
1617
import { usageText } from "./usage.js"
@@ -71,13 +72,15 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
7172
Match.when("stop-all", () => Either.right(downAllCommand)),
7273
Match.when("kill-all", () => Either.right(downAllCommand)),
7374
Match.when("menu", () => Either.right(menuCommand)),
74-
Match.when("ui", () => Either.right(menuCommand)),
75-
Match.when("auth", () => parseAuth(rest))
75+
Match.when("ui", () => Either.right(menuCommand))
7676
)
7777
.pipe(
78+
Match.when("auth", () => parseAuth(rest)),
7879
Match.when("open", () => parseAttach(rest)),
7980
Match.when("apply", () => parseApply(rest)),
8081
Match.when("state", () => parseState(rest)),
82+
Match.when("session-gists", () => parseSessionGists(rest)),
83+
Match.when("gists", () => parseSessionGists(rest)),
8184
Match.orElse(() => Either.left(unknownCommandError))
8285
)
8386
}

packages/app/src/docker-git/cli/usage.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ docker-git scrap <action> [<url>] [options]
1414
docker-git sessions [list] [<url>] [options]
1515
docker-git sessions kill <pid> [<url>] [options]
1616
docker-git sessions logs <pid> [<url>] [options]
17+
docker-git session-gists [list] [options]
18+
docker-git session-gists backup [<url>] [options]
19+
docker-git session-gists view <snapshot-ref>
20+
docker-git session-gists download <snapshot-ref> [options]
1721
docker-git ps
1822
docker-git down-all
1923
docker-git auth <provider> <action> [options]
@@ -30,6 +34,7 @@ Commands:
3034
panes, terms List tmux panes for a docker-git project
3135
scrap Export/import project scrap (session snapshot + rebuildable deps)
3236
sessions List/kill/log container terminal processes
37+
session-gists Manage AI session backups via a private session repository (backup/list/view/download)
3338
ps, status Show docker compose status for all docker-git projects
3439
down-all Stop all docker-git containers (docker compose down)
3540
auth Manage GitHub/Codex/Claude Code auth for docker-git
@@ -64,6 +69,11 @@ Options:
6469
--wipe | --no-wipe Wipe workspace before scrap import (default: --wipe)
6570
--lines <n> Tail last N lines for sessions logs (default: 200)
6671
--include-default Show default/system processes in sessions list
72+
--pr-number <n> PR number for session backup comment
73+
--repo <owner/repo> Repository for session backup operations
74+
--limit <n> Limit for session backup snapshot list (default: 20)
75+
--output <path> Output directory for session backup download (default: ./.session-restore)
76+
--no-comment Skip posting PR comment after session backup
6777
--up | --no-up Run docker compose up after init (default: --up)
6878
--ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh)
6979
--mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright)

packages/app/src/docker-git/program.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import { renderError } from "@effect-template/lib/usecases/errors"
2121
import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright"
2222
import { downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects"
2323
import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap"
24+
import {
25+
sessionGistBackup,
26+
sessionGistDownload,
27+
sessionGistList,
28+
sessionGistView
29+
} from "@effect-template/lib/usecases/session-gists"
2430
import {
2531
stateCommit,
2632
stateInit,
@@ -110,6 +116,10 @@ const handleNonBaseCommand = (command: NonBaseCommand) =>
110116
Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)),
111117
Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)),
112118
Match.when({ _tag: "McpPlaywrightUp" }, (cmd) => mcpPlaywrightUp(cmd)),
119+
Match.when({ _tag: "SessionGistBackup" }, (cmd) => sessionGistBackup(cmd)),
120+
Match.when({ _tag: "SessionGistList" }, (cmd) => sessionGistList(cmd)),
121+
Match.when({ _tag: "SessionGistView" }, (cmd) => sessionGistView(cmd)),
122+
Match.when({ _tag: "SessionGistDownload" }, (cmd) => sessionGistDownload(cmd)),
113123
Match.exhaustive
114124
)
115125

packages/lib/src/core/command-options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ export interface RawOptions {
5151
readonly force?: boolean
5252
readonly forceEnv?: boolean
5353
readonly agentAutoMode?: string
54+
// Session gist options (issue-143)
55+
readonly prNumber?: string
56+
readonly repo?: string
57+
readonly noComment?: boolean
58+
readonly limit?: string
59+
readonly output?: string
5460
}
5561

5662
// CHANGE: helper type alias for builder signatures that produce parse errors

0 commit comments

Comments
 (0)