Skip to content

Commit 3314d03

Browse files
authored
feat: allow optional --save-script output path (#63)
1 parent 586716d commit 3314d03

12 files changed

Lines changed: 123 additions & 14 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ Sessions:
145145
- `close` stops the session and releases device resources. Pass an app to close it explicitly, or omit to just close the session.
146146
- Use `--session <name>` to manage multiple sessions.
147147
- Session scripts are written to `~/.agent-device/sessions/<session>-<timestamp>.ad` when recording is enabled with `--save-script`.
148+
- `--save-script` accepts an optional path: `--save-script ./workflows/my-flow.ad`.
149+
- For ambiguous bare values, use an explicit form: `--save-script=workflow.ad` or a path-like value such as `./workflow.ad`.
148150
- Deterministic replay is `.ad`-based; use `replay --update` (`-u`) to update selector drift and rewrite the replay file in place.
149151

150152
Navigation helpers:

skills/agent-device/SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,16 @@ agent-device screenshot out.png
143143

144144
```bash
145145
agent-device open App --relaunch # Fresh app process restart in the current session
146-
agent-device open App --save-script # Save session script (.ad) on close
146+
agent-device open App --save-script # Save session script (.ad) on close (default path)
147+
agent-device open App --save-script ./workflows/app-flow.ad # Save to custom file path
147148
agent-device replay ./session.ad # Run deterministic replay from .ad script
148149
agent-device replay -u ./session.ad # Update selector drift and rewrite .ad script in place
149150
```
150151

151152
`replay` reads `.ad` recordings.
152153
`--relaunch` controls launch semantics; `--save-script` controls recording. Combine only when both are needed.
154+
`--save-script` path is a file path; parent directories are created automatically.
155+
For ambiguous bare values, use `--save-script=workflow.ad` or `./workflow.ad`.
153156

154157
### Trace logs (AX/XCTest)
155158

skills/agent-device/references/session-management.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ Sessions isolate device context. A device can only be held by one session at a t
1616
- Use separate sessions for parallel work.
1717
- In iOS sessions, use `open <app>` for simulator/device. `open <url>` is simulator-only.
1818
- For dev loops where runtime state can persist (for example React Native Fast Refresh), use `open <app> --relaunch` to restart the app process in the same session.
19+
- Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically.
20+
- For ambiguous bare `--save-script` values, prefer `--save-script=workflow.ad` or `./workflow.ad`.
1921
- For deterministic replay scripts, prefer selector-based actions and assertions.
2022
- Use `replay -u` to update selector drift during maintenance.
2123

src/daemon/__tests__/session-store.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,29 @@ test('saveScript flag enables .ad session log writing', () => {
9494
assert.equal(files.filter((file) => file.endsWith('.ad')).length, 1);
9595
});
9696

97+
test('saveScript path writes session log to custom location', () => {
98+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-custom-path-'));
99+
const store = new SessionStore(path.join(root, 'sessions'));
100+
const session = makeSession('default');
101+
const customPath = path.join(root, 'workflows', 'my-flow.ad');
102+
store.recordAction(session, {
103+
command: 'open',
104+
positionals: ['Settings'],
105+
flags: { platform: 'ios', saveScript: customPath },
106+
result: {},
107+
});
108+
store.recordAction(session, {
109+
command: 'close',
110+
positionals: [],
111+
flags: { platform: 'ios' },
112+
result: {},
113+
});
114+
115+
store.writeSessionLog(session);
116+
assert.equal(fs.existsSync(customPath), true);
117+
assert.equal(fs.existsSync(path.join(root, 'sessions')), false);
118+
});
119+
97120
test('writeSessionLog persists open --relaunch in script output', () => {
98121
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-relaunch-'));
99122
const store = new SessionStore(root);

src/daemon/handlers/session.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ export async function handleSessionCommands(params: {
346346
...session,
347347
appBundleId,
348348
appName: openTarget,
349-
recordSession: session.recordSession || req.flags?.saveScript === true,
349+
recordSession: session.recordSession || Boolean(req.flags?.saveScript),
350350
snapshot: undefined,
351351
};
352352
sessionStore.recordAction(nextSession, {
@@ -405,7 +405,7 @@ export async function handleSessionCommands(params: {
405405
createdAt: Date.now(),
406406
appBundleId,
407407
appName: openTarget,
408-
recordSession: req.flags?.saveScript === true,
408+
recordSession: Boolean(req.flags?.saveScript),
409409
actions: [],
410410
};
411411
sessionStore.recordAction(session, {

src/daemon/session-store.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export class SessionStore {
4949
if (entry.flags?.noRecord) return;
5050
if (entry.flags?.saveScript) {
5151
session.recordSession = true;
52+
if (typeof entry.flags.saveScript === 'string') {
53+
session.saveScriptPath = SessionStore.expandHome(entry.flags.saveScript);
54+
}
5255
}
5356
session.actions.push({
5457
ts: Date.now(),
@@ -62,10 +65,9 @@ export class SessionStore {
6265
writeSessionLog(session: SessionState): void {
6366
try {
6467
if (!session.recordSession) return;
65-
if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true });
66-
const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
67-
const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-');
68-
const scriptPath = path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`);
68+
const scriptPath = this.resolveScriptPath(session);
69+
const scriptDir = path.dirname(scriptPath);
70+
if (!fs.existsSync(scriptDir)) fs.mkdirSync(scriptDir, { recursive: true });
6971
const script = formatScript(session, this.buildOptimizedActions(session));
7072
fs.writeFileSync(scriptPath, script);
7173
} catch {
@@ -86,6 +88,16 @@ export class SessionStore {
8688
return path.resolve(filePath);
8789
}
8890

91+
private resolveScriptPath(session: SessionState): string {
92+
if (session.saveScriptPath) {
93+
return SessionStore.expandHome(session.saveScriptPath);
94+
}
95+
if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true });
96+
const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
97+
const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-');
98+
return path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`);
99+
}
100+
89101
private buildOptimizedActions(session: SessionState): SessionAction[] {
90102
const optimized: SessionAction[] = [];
91103
for (const action of session.actions) {

src/daemon/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type SessionState = {
2727
startedAt: number;
2828
};
2929
recordSession?: boolean;
30+
saveScriptPath?: string;
3031
actions: SessionAction[];
3132
recording?: {
3233
platform: 'ios' | 'android';
@@ -48,7 +49,7 @@ export type SessionAction = {
4849
snapshotScope?: string;
4950
snapshotRaw?: boolean;
5051
snapshotBackend?: 'ax' | 'xctest';
51-
saveScript?: boolean;
52+
saveScript?: boolean | string;
5253
noRecord?: boolean;
5354
};
5455
result?: Record<string, unknown>;

src/utils/__tests__/args.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,33 @@ test('parseArgs recognizes --relaunch', () => {
1212
assert.equal(parsed.flags.relaunch, true);
1313
});
1414

15+
test('parseArgs accepts --save-script with optional path value', () => {
16+
const withoutPath = parseArgs(['open', 'settings', '--save-script']);
17+
assert.equal(withoutPath.command, 'open');
18+
assert.deepEqual(withoutPath.positionals, ['settings']);
19+
assert.equal(withoutPath.flags.saveScript, true);
20+
21+
const withPath = parseArgs(['open', 'settings', '--save-script', './workflows/my-flow.ad']);
22+
assert.equal(withPath.command, 'open');
23+
assert.deepEqual(withPath.positionals, ['settings']);
24+
assert.equal(withPath.flags.saveScript, './workflows/my-flow.ad');
25+
26+
const nonPathPositional = parseArgs(['open', '--save-script', 'settings']);
27+
assert.equal(nonPathPositional.command, 'open');
28+
assert.deepEqual(nonPathPositional.positionals, ['settings']);
29+
assert.equal(nonPathPositional.flags.saveScript, true);
30+
31+
const inlineValue = parseArgs(['open', 'settings', '--save-script=my-flow.ad']);
32+
assert.equal(inlineValue.command, 'open');
33+
assert.deepEqual(inlineValue.positionals, ['settings']);
34+
assert.equal(inlineValue.flags.saveScript, 'my-flow.ad');
35+
36+
const ambiguousBareValue = parseArgs(['open', '--save-script', 'my-flow.ad']);
37+
assert.equal(ambiguousBareValue.command, 'open');
38+
assert.deepEqual(ambiguousBareValue.positionals, ['my-flow.ad']);
39+
assert.equal(ambiguousBareValue.flags.saveScript, true);
40+
});
41+
1542
test('parseArgs recognizes press series flags', () => {
1643
const parsed = parseArgs([
1744
'press',
@@ -64,6 +91,7 @@ test('parseArgs rejects invalid swipe pattern', () => {
6491

6592
test('usage includes --relaunch flag', () => {
6693
assert.match(usage(), /--relaunch/);
94+
assert.match(usage(), /--save-script \[path\]/);
6795
assert.match(usage(), /pinch <scale> \[x\] \[y\]/);
6896
assert.match(usage(), /--metadata/);
6997
});

src/utils/args.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ function parseFlagValue(
123123
}
124124
return { value: true, consumeNext: false };
125125
}
126+
if (definition.type === 'booleanOrString') {
127+
if (inlineValue !== undefined) {
128+
if (inlineValue.trim().length === 0) {
129+
throw new AppError('INVALID_ARGS', `Flag ${token} requires a non-empty value when provided.`);
130+
}
131+
return { value: inlineValue, consumeNext: false };
132+
}
133+
if (nextArg === undefined || looksLikeFlagToken(nextArg)) {
134+
return { value: true, consumeNext: false };
135+
}
136+
if (shouldConsumeOptionalPathValue(nextArg)) {
137+
return { value: nextArg, consumeNext: true };
138+
}
139+
return { value: true, consumeNext: false };
140+
}
126141

127142
const value = inlineValue ?? nextArg;
128143
if (value === undefined) {
@@ -164,6 +179,17 @@ function looksLikeFlagToken(value: string): boolean {
164179
return getFlagDefinition(token) !== undefined;
165180
}
166181

182+
function shouldConsumeOptionalPathValue(value: string): boolean {
183+
const trimmed = value.trim();
184+
if (!trimmed) return false;
185+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return false;
186+
if (trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~/') || trimmed.startsWith('/')) {
187+
return true;
188+
}
189+
if (trimmed.includes('/') || trimmed.includes('\\')) return true;
190+
return false;
191+
}
192+
167193
function shouldTreatUnknownDashTokenAsPositional(
168194
command: string | null,
169195
positionals: string[],

src/utils/command-schema.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export type CliFlags = {
2222
pauseMs?: number;
2323
pattern?: 'one-way' | 'ping-pong';
2424
activity?: string;
25-
saveScript?: boolean;
25+
saveScript?: boolean | string;
2626
relaunch?: boolean;
2727
noRecord?: boolean;
2828
replayUpdate?: boolean;
@@ -31,7 +31,7 @@ export type CliFlags = {
3131
};
3232

3333
export type FlagKey = keyof CliFlags;
34-
export type FlagType = 'boolean' | 'int' | 'enum' | 'string';
34+
export type FlagType = 'boolean' | 'int' | 'enum' | 'string' | 'booleanOrString';
3535

3636
export type FlagDefinition = {
3737
key: FlagKey;
@@ -201,9 +201,9 @@ export const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
201201
{
202202
key: 'saveScript',
203203
names: ['--save-script'],
204-
type: 'boolean',
205-
usageLabel: '--save-script',
206-
usageDescription: 'Save session script (.ad) on close',
204+
type: 'booleanOrString',
205+
usageLabel: '--save-script [path]',
206+
usageDescription: 'Save session script (.ad) on close; optional custom output path',
207207
},
208208
{
209209
key: 'relaunch',

0 commit comments

Comments
 (0)