diff --git a/README.md b/README.md index bd4d8e6d8..93474e687 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,8 @@ Sessions: - `close` stops the session and releases device resources. Pass an app to close it explicitly, or omit to just close the session. - Use `--session ` to manage multiple sessions. - Session scripts are written to `~/.agent-device/sessions/-.ad` when recording is enabled with `--save-script`. +- `--save-script` accepts an optional path: `--save-script ./workflows/my-flow.ad`. +- For ambiguous bare values, use an explicit form: `--save-script=workflow.ad` or a path-like value such as `./workflow.ad`. - Deterministic replay is `.ad`-based; use `replay --update` (`-u`) to update selector drift and rewrite the replay file in place. Navigation helpers: diff --git a/skills/agent-device/SKILL.md b/skills/agent-device/SKILL.md index bac99beb1..335451b13 100644 --- a/skills/agent-device/SKILL.md +++ b/skills/agent-device/SKILL.md @@ -143,13 +143,16 @@ agent-device screenshot out.png ```bash agent-device open App --relaunch # Fresh app process restart in the current session -agent-device open App --save-script # Save session script (.ad) on close +agent-device open App --save-script # Save session script (.ad) on close (default path) +agent-device open App --save-script ./workflows/app-flow.ad # Save to custom file path agent-device replay ./session.ad # Run deterministic replay from .ad script agent-device replay -u ./session.ad # Update selector drift and rewrite .ad script in place ``` `replay` reads `.ad` recordings. `--relaunch` controls launch semantics; `--save-script` controls recording. Combine only when both are needed. +`--save-script` path is a file path; parent directories are created automatically. +For ambiguous bare values, use `--save-script=workflow.ad` or `./workflow.ad`. ### Trace logs (AX/XCTest) diff --git a/skills/agent-device/references/session-management.md b/skills/agent-device/references/session-management.md index 22eaa7b56..09a0dfb05 100644 --- a/skills/agent-device/references/session-management.md +++ b/skills/agent-device/references/session-management.md @@ -16,6 +16,8 @@ Sessions isolate device context. A device can only be held by one session at a t - Use separate sessions for parallel work. - In iOS sessions, use `open ` for simulator/device. `open ` is simulator-only. - For dev loops where runtime state can persist (for example React Native Fast Refresh), use `open --relaunch` to restart the app process in the same session. +- Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically. +- For ambiguous bare `--save-script` values, prefer `--save-script=workflow.ad` or `./workflow.ad`. - For deterministic replay scripts, prefer selector-based actions and assertions. - Use `replay -u` to update selector drift during maintenance. diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 044e23aa8..e611970b9 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -94,6 +94,29 @@ test('saveScript flag enables .ad session log writing', () => { assert.equal(files.filter((file) => file.endsWith('.ad')).length, 1); }); +test('saveScript path writes session log to custom location', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-custom-path-')); + const store = new SessionStore(path.join(root, 'sessions')); + const session = makeSession('default'); + const customPath = path.join(root, 'workflows', 'my-flow.ad'); + store.recordAction(session, { + command: 'open', + positionals: ['Settings'], + flags: { platform: 'ios', saveScript: customPath }, + result: {}, + }); + store.recordAction(session, { + command: 'close', + positionals: [], + flags: { platform: 'ios' }, + result: {}, + }); + + store.writeSessionLog(session); + assert.equal(fs.existsSync(customPath), true); + assert.equal(fs.existsSync(path.join(root, 'sessions')), false); +}); + test('writeSessionLog persists open --relaunch in script output', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-relaunch-')); const store = new SessionStore(root); diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 4f3f08c41..be778cf00 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -346,7 +346,7 @@ export async function handleSessionCommands(params: { ...session, appBundleId, appName: openTarget, - recordSession: session.recordSession || req.flags?.saveScript === true, + recordSession: session.recordSession || Boolean(req.flags?.saveScript), snapshot: undefined, }; sessionStore.recordAction(nextSession, { @@ -405,7 +405,7 @@ export async function handleSessionCommands(params: { createdAt: Date.now(), appBundleId, appName: openTarget, - recordSession: req.flags?.saveScript === true, + recordSession: Boolean(req.flags?.saveScript), actions: [], }; sessionStore.recordAction(session, { diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index abd53980b..76fc8c262 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -49,6 +49,9 @@ export class SessionStore { if (entry.flags?.noRecord) return; if (entry.flags?.saveScript) { session.recordSession = true; + if (typeof entry.flags.saveScript === 'string') { + session.saveScriptPath = SessionStore.expandHome(entry.flags.saveScript); + } } session.actions.push({ ts: Date.now(), @@ -62,10 +65,9 @@ export class SessionStore { writeSessionLog(session: SessionState): void { try { if (!session.recordSession) return; - if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true }); - const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_'); - const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-'); - const scriptPath = path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`); + const scriptPath = this.resolveScriptPath(session); + const scriptDir = path.dirname(scriptPath); + if (!fs.existsSync(scriptDir)) fs.mkdirSync(scriptDir, { recursive: true }); const script = formatScript(session, this.buildOptimizedActions(session)); fs.writeFileSync(scriptPath, script); } catch { @@ -86,6 +88,16 @@ export class SessionStore { return path.resolve(filePath); } + private resolveScriptPath(session: SessionState): string { + if (session.saveScriptPath) { + return SessionStore.expandHome(session.saveScriptPath); + } + if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true }); + const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-'); + return path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`); + } + private buildOptimizedActions(session: SessionState): SessionAction[] { const optimized: SessionAction[] = []; for (const action of session.actions) { diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 7a91d61a7..32f45960c 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -27,6 +27,7 @@ export type SessionState = { startedAt: number; }; recordSession?: boolean; + saveScriptPath?: string; actions: SessionAction[]; recording?: { platform: 'ios' | 'android'; @@ -48,7 +49,7 @@ export type SessionAction = { snapshotScope?: string; snapshotRaw?: boolean; snapshotBackend?: 'ax' | 'xctest'; - saveScript?: boolean; + saveScript?: boolean | string; noRecord?: boolean; }; result?: Record; diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index b499f4974..f77c0cfa1 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -12,6 +12,33 @@ test('parseArgs recognizes --relaunch', () => { assert.equal(parsed.flags.relaunch, true); }); +test('parseArgs accepts --save-script with optional path value', () => { + const withoutPath = parseArgs(['open', 'settings', '--save-script']); + assert.equal(withoutPath.command, 'open'); + assert.deepEqual(withoutPath.positionals, ['settings']); + assert.equal(withoutPath.flags.saveScript, true); + + const withPath = parseArgs(['open', 'settings', '--save-script', './workflows/my-flow.ad']); + assert.equal(withPath.command, 'open'); + assert.deepEqual(withPath.positionals, ['settings']); + assert.equal(withPath.flags.saveScript, './workflows/my-flow.ad'); + + const nonPathPositional = parseArgs(['open', '--save-script', 'settings']); + assert.equal(nonPathPositional.command, 'open'); + assert.deepEqual(nonPathPositional.positionals, ['settings']); + assert.equal(nonPathPositional.flags.saveScript, true); + + const inlineValue = parseArgs(['open', 'settings', '--save-script=my-flow.ad']); + assert.equal(inlineValue.command, 'open'); + assert.deepEqual(inlineValue.positionals, ['settings']); + assert.equal(inlineValue.flags.saveScript, 'my-flow.ad'); + + const ambiguousBareValue = parseArgs(['open', '--save-script', 'my-flow.ad']); + assert.equal(ambiguousBareValue.command, 'open'); + assert.deepEqual(ambiguousBareValue.positionals, ['my-flow.ad']); + assert.equal(ambiguousBareValue.flags.saveScript, true); +}); + test('parseArgs recognizes press series flags', () => { const parsed = parseArgs([ 'press', @@ -64,6 +91,7 @@ test('parseArgs rejects invalid swipe pattern', () => { test('usage includes --relaunch flag', () => { assert.match(usage(), /--relaunch/); + assert.match(usage(), /--save-script \[path\]/); assert.match(usage(), /pinch \[x\] \[y\]/); assert.match(usage(), /--metadata/); }); diff --git a/src/utils/args.ts b/src/utils/args.ts index 861405660..f03973dd8 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -122,6 +122,21 @@ function parseFlagValue( } return { value: true, consumeNext: false }; } + if (definition.type === 'booleanOrString') { + if (inlineValue !== undefined) { + if (inlineValue.trim().length === 0) { + throw new AppError('INVALID_ARGS', `Flag ${token} requires a non-empty value when provided.`); + } + return { value: inlineValue, consumeNext: false }; + } + if (nextArg === undefined || looksLikeFlagToken(nextArg)) { + return { value: true, consumeNext: false }; + } + if (shouldConsumeOptionalPathValue(nextArg)) { + return { value: nextArg, consumeNext: true }; + } + return { value: true, consumeNext: false }; + } const value = inlineValue ?? nextArg; if (value === undefined) { @@ -163,6 +178,17 @@ function looksLikeFlagToken(value: string): boolean { return getFlagDefinition(token) !== undefined; } +function shouldConsumeOptionalPathValue(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return false; + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return false; + if (trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~/') || trimmed.startsWith('/')) { + return true; + } + if (trimmed.includes('/') || trimmed.includes('\\')) return true; + return false; +} + function shouldTreatUnknownDashTokenAsPositional( command: string | null, positionals: string[], diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index c484de9a8..7f71edef5 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -22,7 +22,7 @@ export type CliFlags = { pauseMs?: number; pattern?: 'one-way' | 'ping-pong'; activity?: string; - saveScript?: boolean; + saveScript?: boolean | string; relaunch?: boolean; noRecord?: boolean; replayUpdate?: boolean; @@ -31,7 +31,7 @@ export type CliFlags = { }; export type FlagKey = keyof CliFlags; -export type FlagType = 'boolean' | 'int' | 'enum' | 'string'; +export type FlagType = 'boolean' | 'int' | 'enum' | 'string' | 'booleanOrString'; export type FlagDefinition = { key: FlagKey; @@ -201,9 +201,9 @@ export const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ { key: 'saveScript', names: ['--save-script'], - type: 'boolean', - usageLabel: '--save-script', - usageDescription: 'Save session script (.ad) on close', + type: 'booleanOrString', + usageLabel: '--save-script [path]', + usageDescription: 'Save session script (.ad) on close; optional custom output path', }, { key: 'relaunch', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 8de962ba3..4c5297e63 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -72,12 +72,14 @@ agent-device find role button click ## Replay ```bash +agent-device open Settings --platform ios --session e2e --save-script [path] agent-device replay ./session.ad # Run deterministic replay from .ad script agent-device replay -u ./session.ad # Update selector drift and rewrite .ad script in place ``` - `replay` runs deterministic `.ad` scripts. - `replay -u` updates stale recorded actions and rewrites the same script. +- `--save-script` records a replay script on `close`; optional path is a file path and parent directories are created. See [Replay & E2E (Experimental)](/docs/replay-e2e) for recording and CI workflow details. diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index b3e0bf012..38bc0b2e9 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -24,12 +24,22 @@ agent-device click @e13 --session e2e agent-device close --session e2e ``` -On `close`, a replay script is written to: +By default, on `close`, a replay script is written to: ```text ~/.agent-device/sessions/-.ad ``` +You can also provide a custom output file path: + +```bash +agent-device open Settings --platform ios --session e2e --save-script ./workflows/e2e-settings.ad +``` + +- `--save-script` value is treated as a file path. +- Parent directories are created automatically when they do not exist. +- For ambiguous bare values, use `--save-script=workflow.ad` or a path-like value such as `./workflow.ad`. + ## Run replay ```bash