agent-device 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -30
- package/dist/bin/axsnapshot +0 -0
- package/dist/src/bin.js +12 -13
- package/dist/src/daemon.js +8 -8
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +135 -61
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +11 -13
- package/skills/agent-device/references/snapshot-refs.md +1 -1
- package/src/core/dispatch.ts +13 -133
- package/src/daemon.ts +62 -68
- package/src/platforms/ios/runner-client.ts +1 -55
- package/src/utils/args.ts +4 -5
- package/src/utils/snapshot.ts +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# agent-device
|
|
2
2
|
|
|
3
|
-
CLI to control iOS and Android devices for AI agents.
|
|
3
|
+
CLI to control iOS and Android devices for AI agents influenced by Vercel’s [agent-browser](https://github.com/vercel/agent-browser).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The project is in early development and considered experimental. Pull requests are welcome!
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Features
|
|
8
8
|
- Platforms: iOS (simulator + limited device support) and Android (emulator + device).
|
|
9
9
|
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`.
|
|
10
10
|
- Inspection commands: `snapshot` (accessibility tree).
|
|
@@ -23,40 +23,67 @@ Or use it without installing:
|
|
|
23
23
|
npx agent-device open SampleApp
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
agent-device open Contacts --platform ios
|
|
30
|
+
agent-device snapshot -i -c --platform ios
|
|
31
|
+
agent-device click @e5 --platform ios
|
|
32
|
+
agent-device fill @e6 "John" --platform ios
|
|
33
|
+
agent-device fill @e7 "Doe" --platform ios
|
|
34
|
+
agent-device click @e3 --platform ios
|
|
35
|
+
agent-device close
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## CLI Usage
|
|
27
39
|
|
|
28
40
|
```bash
|
|
29
41
|
agent-device <command> [args] [--json]
|
|
30
42
|
```
|
|
31
43
|
|
|
32
|
-
|
|
44
|
+
Basic flow:
|
|
33
45
|
|
|
34
46
|
```bash
|
|
35
47
|
agent-device open SampleApp
|
|
36
48
|
agent-device snapshot
|
|
37
|
-
agent-device snapshot -s @e7
|
|
38
49
|
agent-device click @e7
|
|
39
|
-
agent-device
|
|
40
|
-
agent-device alert wait 10000
|
|
41
|
-
agent-device back
|
|
42
|
-
agent-device type "hello"
|
|
43
|
-
agent-device screenshot --out ./screenshot.png
|
|
50
|
+
agent-device fill @e8 "hello"
|
|
44
51
|
agent-device close SampleApp
|
|
45
52
|
```
|
|
46
53
|
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
Debug flow:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
agent-device trace start
|
|
58
|
+
agent-device snapshot --backend xctest -s "Sample App"
|
|
59
|
+
agent-device find label "Wi-Fi" click
|
|
60
|
+
agent-device trace stop ./trace.log
|
|
61
|
+
```
|
|
49
62
|
|
|
50
63
|
Coordinates:
|
|
51
64
|
- All coordinate-based commands (`press`, `long-press`, `focus`, `fill`) use device coordinates with origin at top-left.
|
|
52
65
|
- X increases to the right, Y increases downward.
|
|
53
66
|
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
- `
|
|
57
|
-
- `
|
|
58
|
-
-
|
|
59
|
-
|
|
67
|
+
## Command Index
|
|
68
|
+
- `open`, `close`, `home`, `back`, `app-switcher`
|
|
69
|
+
- `snapshot`, `find`, `get`
|
|
70
|
+
- `click`, `focus`, `type`, `fill`, `press`, `long-press`, `scroll`, `scrollintoview`
|
|
71
|
+
- `alert`, `wait`, `screenshot`
|
|
72
|
+
- `trace start`, `trace stop`
|
|
73
|
+
- `settings wifi|airplane|location on|off`
|
|
74
|
+
- `appstate`, `apps`, `devices`, `session list`
|
|
75
|
+
|
|
76
|
+
## Backends (iOS snapshots)
|
|
77
|
+
|
|
78
|
+
| Backend | Speed | Accuracy | Requirements |
|
|
79
|
+
| --- | --- | --- | --- |
|
|
80
|
+
| `ax` | Fast | Medium | Accessibility permission for the terminal app |
|
|
81
|
+
| `xctest` | Fast | High | No Accessibility permission required |
|
|
82
|
+
|
|
83
|
+
Notes:
|
|
84
|
+
- Default backend is `xctest` on iOS.
|
|
85
|
+
- Scope snapshots with `-s "<label>"` or `-s @ref`.
|
|
86
|
+
- If XCTest returns 0 nodes (e.g., foreground app changed), agent-device falls back to AX when available.
|
|
60
87
|
|
|
61
88
|
Flags:
|
|
62
89
|
- `--platform ios|android`
|
|
@@ -67,21 +94,19 @@ Flags:
|
|
|
67
94
|
- `--session <name>`
|
|
68
95
|
- `--verbose` for daemon and runner logs
|
|
69
96
|
- `--json` for structured output
|
|
70
|
-
- `--backend ax|xctest
|
|
97
|
+
- `--backend ax|xctest` (snapshot only; defaults to `xctest` on iOS)
|
|
71
98
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
- `trace stop [path]` to stop capture and optionally move the trace log.
|
|
99
|
+
## Skills
|
|
100
|
+
Install the automation skills listed in [SKILL.md](skills/agent-device/SKILL.md).
|
|
75
101
|
|
|
76
102
|
Sessions:
|
|
77
103
|
- `open` starts a session. Without args boots/activates the target device/simulator without launching an app.
|
|
78
104
|
- All interaction commands require an open session.
|
|
105
|
+
- If a session is already open, `open <app>` switches the active app and updates the session app bundle.
|
|
79
106
|
- `close` stops the session and releases device resources. Pass an app to close it explicitly, or omit to just close the session.
|
|
80
107
|
- Use `--session <name>` to manage multiple sessions.
|
|
81
108
|
- Session logs are written to `~/.agent-device/sessions/<session>-<timestamp>.ad`.
|
|
82
109
|
|
|
83
|
-
Snapshot defaults to the hybrid backend on iOS simulators. Use `--backend ax` for AX-only or `--backend xctest` for XCTest-only.
|
|
84
|
-
|
|
85
110
|
Find (semantic):
|
|
86
111
|
- `find <text> <action> [value]` finds by any text (label/value/identifier) using a scoped snapshot.
|
|
87
112
|
- `find text|label|value|role|id <value> <action> [value]` for specific locators.
|
|
@@ -90,8 +115,8 @@ Find (semantic):
|
|
|
90
115
|
Settings helpers (simulators):
|
|
91
116
|
- `settings wifi on|off`
|
|
92
117
|
- `settings airplane on|off`
|
|
93
|
-
- `settings location on|off` (iOS uses per
|
|
94
|
-
|
|
118
|
+
- `settings location on|off` (iOS uses per-app permission for the current session app)
|
|
119
|
+
Note: iOS wifi/airplane toggles status bar indicators, not actual network state. Airplane off clears status bar overrides.
|
|
95
120
|
|
|
96
121
|
App state:
|
|
97
122
|
- `appstate` shows the foreground app/activity (Android). On iOS it uses the current session app when available, otherwise it falls back to a snapshot-based guess (AX first, XCTest if AX can’t identify).
|
|
@@ -99,9 +124,8 @@ App state:
|
|
|
99
124
|
|
|
100
125
|
## Debug
|
|
101
126
|
|
|
102
|
-
-
|
|
103
|
-
|
|
104
|
-
- `agent-device trace stop ./trace.log`
|
|
127
|
+
- `agent-device trace start`
|
|
128
|
+
- `agent-device trace stop ./trace.log`
|
|
105
129
|
- The trace log includes AX snapshot stderr and XCTest runner logs for the session.
|
|
106
130
|
- Built-in retries cover transient runner connection failures, AX snapshot hiccups, and Android UI dumps.
|
|
107
131
|
- For snapshot issues, compare `--backend ax` vs `--backend xctest` and scope with `-s "<label>"`.
|
package/dist/bin/axsnapshot
CHANGED
|
Binary file
|
package/dist/src/bin.js
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
import{node_path as e,fileURLToPath as t,asAppError as r,pathToFileURL as n,runCmdDetached as s,node_fs as o,node_os as a,node_net as i,errors_AppError as l}from"./861.js";function c(e){process.stdout.write(`${JSON.stringify(e,null,2)}
|
|
2
|
-
`)}function
|
|
2
|
+
`)}function u(e){let t=e.details?`
|
|
3
3
|
${JSON.stringify(e.details,null,2)}`:"";process.stderr.write(`Error (${e.code}): ${e.message}${t}
|
|
4
|
-
`)}let
|
|
5
|
-
`)}),o=setTimeout(()=>{s.destroy(),n(new l("COMMAND_FAILED","Daemon request timed out",{timeoutMs:f}))},f),a="";s.setEncoding("utf8"),s.on("data",e=>{let t=(a+=e).indexOf("\n");if(-1===t)return;let i=a.slice(0,t).trim();if(i)try{let e=JSON.parse(i);s.end(),clearTimeout(o),r(e)}catch(e){clearTimeout(o),n(e)}}),s.on("error",e=>{clearTimeout(o),n(e)})})}function v(){let r=e.dirname(t(import.meta.url)),n=r;for(let t=0;t<6;t+=1){let t=e.join(n,"package.json");if(o.existsSync(t))return n;n=e.dirname(n)}return r}async function $(t){let n=function(e){let t={json:!1,help:!1},r=[];for(let n=0;n<e.length;n+=1){let s=e[n];if("--json"===s){t.json=!0;continue}if("--help"===s||"-h"===s){t.help=!0;continue}if("--verbose"===s||"-v"===s){t.verbose=!0;continue}if("-i"===s){t.snapshotInteractiveOnly=!0;continue}if("-c"===s){t.snapshotCompact=!0;continue}if("--raw"===s){t.snapshotRaw=!0;continue}if("--no-record"===s){t.noRecord=!0;continue}if("--record-json"===s){t.recordJson=!0;continue}if("--user-installed"===s){t.appsFilter="user-installed";continue}if("--all"===s){t.appsFilter="all";continue}if("--metadata"===s){t.appsMetadata=!0;continue}if(s.startsWith("--backend")){let r=s.includes("=")?s.split("=")[1]:e[n+1];if(s.includes("=")||(n+=1),"ax"!==r&&"xctest"!==r
|
|
4
|
+
`)}let d=e.join(a.homedir(),".agent-device"),p=e.join(d,"daemon.json"),f=function(){let e=process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS;if(!e)return 6e4;let t=Number(e);return Number.isFinite(t)?Math.max(1e3,Math.floor(t)):6e4}();async function m(e){let t=await h(),r={...e,token:t.token};return await b(t,r)}async function h(){let t=w(),r=function(){try{let t=v();return JSON.parse(o.readFileSync(e.join(t,"package.json"),"utf8")).version??"0.0.0"}catch{return"0.0.0"}}();if(t&&t.version===r&&await y(t))return t;t&&(t.version!==r||!await y(t))&&o.existsSync(p)&&o.unlinkSync(p),await g();let n=Date.now();for(;Date.now()-n<5e3;){let e=w();if(e&&await y(e))return e;await new Promise(e=>setTimeout(e,100))}throw new l("COMMAND_FAILED","Failed to start daemon",{infoPath:p,hint:"Run pnpm build, or delete ~/.agent-device/daemon.json if stale."})}function w(){if(!o.existsSync(p))return null;try{let e=JSON.parse(o.readFileSync(p,"utf8"));if(!e.port||!e.token)return null;return e}catch{return null}}async function y(e){return new Promise(t=>{let r=i.createConnection({host:"127.0.0.1",port:e.port},()=>{r.destroy(),t(!0)});r.on("error",()=>{t(!1)})})}async function g(){let t=v(),r=e.join(t,"dist","src","daemon.js"),n=e.join(t,"src","daemon.ts"),a=o.existsSync(r);if(!a&&!o.existsSync(n))throw new l("COMMAND_FAILED","Daemon entry not found",{distPath:r,srcPath:n});let i=a?[r]:["--experimental-strip-types",n];s(process.execPath,i)}async function b(e,t){return new Promise((r,n)=>{let s=i.createConnection({host:"127.0.0.1",port:e.port},()=>{s.write(`${JSON.stringify(t)}
|
|
5
|
+
`)}),o=setTimeout(()=>{s.destroy(),n(new l("COMMAND_FAILED","Daemon request timed out",{timeoutMs:f}))},f),a="";s.setEncoding("utf8"),s.on("data",e=>{let t=(a+=e).indexOf("\n");if(-1===t)return;let i=a.slice(0,t).trim();if(i)try{let e=JSON.parse(i);s.end(),clearTimeout(o),r(e)}catch(e){clearTimeout(o),n(e)}}),s.on("error",e=>{clearTimeout(o),n(e)})})}function v(){let r=e.dirname(t(import.meta.url)),n=r;for(let t=0;t<6;t+=1){let t=e.join(n,"package.json");if(o.existsSync(t))return n;n=e.dirname(n)}return r}async function $(t){let n=function(e){let t={json:!1,help:!1},r=[];for(let n=0;n<e.length;n+=1){let s=e[n];if("--json"===s){t.json=!0;continue}if("--help"===s||"-h"===s){t.help=!0;continue}if("--verbose"===s||"-v"===s){t.verbose=!0;continue}if("-i"===s){t.snapshotInteractiveOnly=!0;continue}if("-c"===s){t.snapshotCompact=!0;continue}if("--raw"===s){t.snapshotRaw=!0;continue}if("--no-record"===s){t.noRecord=!0;continue}if("--record-json"===s){t.recordJson=!0;continue}if("--user-installed"===s){t.appsFilter="user-installed";continue}if("--all"===s){t.appsFilter="all";continue}if("--metadata"===s){t.appsMetadata=!0;continue}if(s.startsWith("--backend")){let r=s.includes("=")?s.split("=")[1]:e[n+1];if(s.includes("=")||(n+=1),"ax"!==r&&"xctest"!==r)throw new l("INVALID_ARGS",`Invalid backend: ${r}`);t.snapshotBackend=r;continue}if(s.startsWith("--")){let[r,o]=s.split("="),a=o??e[n+1];switch(!o&&(n+=1),r){case"--platform":if("ios"!==a&&"android"!==a)throw new l("INVALID_ARGS",`Invalid platform: ${a}`);t.platform=a;break;case"--depth":{let e=Number(a);if(!Number.isFinite(e)||e<0)throw new l("INVALID_ARGS",`Invalid depth: ${a}`);t.snapshotDepth=Math.floor(e);break}case"--scope":t.snapshotScope=a;break;case"--device":t.device=a;break;case"--udid":t.udid=a;break;case"--serial":t.serial=a;break;case"--out":t.out=a;break;case"--session":t.session=a;break;default:throw new l("INVALID_ARGS",`Unknown flag: ${r}`)}continue}if("-d"===s){let r=e[n+1];n+=1;let s=Number(r);if(!Number.isFinite(s)||s<0)throw new l("INVALID_ARGS",`Invalid depth: ${r}`);t.snapshotDepth=Math.floor(s);continue}if("-s"===s){let r=e[n+1];n+=1,t.snapshotScope=r;continue}r.push(s)}return{command:r.shift()??null,positionals:r,flags:t}}(t);(n.flags.help||!n.command)&&(process.stdout.write(`agent-device <command> [args] [--json]
|
|
6
6
|
|
|
7
7
|
CLI to control iOS and Android devices for AI agents.
|
|
8
8
|
|
|
9
9
|
Commands:
|
|
10
10
|
open [app] Boot device/simulator; optionally launch app
|
|
11
11
|
close [app] Close app or just end session
|
|
12
|
-
snapshot [-i] [-c] [-d <depth>] [-s <scope>] [--raw] [--backend ax|xctest
|
|
12
|
+
snapshot [-i] [-c] [-d <depth>] [-s <scope>] [--raw] [--backend ax|xctest]
|
|
13
13
|
Capture accessibility tree
|
|
14
14
|
-i Interactive elements only
|
|
15
15
|
-c Compact output (drop empty structure)
|
|
16
16
|
-d <depth> Limit snapshot depth
|
|
17
17
|
-s <scope> Scope snapshot to label/identifier
|
|
18
18
|
--raw Raw node output
|
|
19
|
-
--backend ax|xctest
|
|
19
|
+
--backend ax|xctest xctest: default; XCTest snapshot (slower, no permissions)
|
|
20
20
|
ax: macOS Accessibility tree (fast, needs permissions)
|
|
21
|
-
xctest: XCTest snapshot (slower, no permissions)
|
|
22
21
|
devices List available devices
|
|
23
22
|
apps [--user-installed|--all|--metadata] List installed apps (Android launchable by default, iOS simulator)
|
|
24
23
|
appstate Show foreground app/activity
|
|
@@ -66,14 +65,14 @@ Flags:
|
|
|
66
65
|
--user-installed Apps: list user-installed packages (Android only)
|
|
67
66
|
--all Apps: list all packages (Android only)
|
|
68
67
|
|
|
69
|
-
`),process.exit(+!n.flags.help));let{command:s,positionals:i,flags:
|
|
70
|
-
`),f&&f();return}let e=await m({session:p,command:s,positionals:i,flags:
|
|
68
|
+
`),process.exit(+!n.flags.help));let{command:s,positionals:i,flags:d}=n,p=d.session??process.env.AGENT_DEVICE_SESSION??"default",f=d.verbose&&!d.json?function(){try{let t=e.join(a.homedir(),".agent-device","daemon.log"),r=0,n=!1,s=setInterval(()=>{if(n||!o.existsSync(t))return;let e=o.statSync(t);if(e.size<=r)return;let s=o.openSync(t,"r"),a=Buffer.alloc(e.size-r);o.readSync(s,a,0,a.length,r),o.closeSync(s),r=e.size,a.length>0&&process.stdout.write(a.toString("utf8"))},200);return()=>{n=!0,clearInterval(s)}}catch{return null}}():null;try{if("session"===s){let e=i[0]??"list";if("list"!==e)throw new l("INVALID_ARGS","session only supports list");let t=await m({session:p,command:"session_list",positionals:[],flags:{}});if(!t.ok)throw new l(t.error.code,t.error.message);d.json?c({success:!0,data:t.data??{}}):process.stdout.write(`${JSON.stringify(t.data??{},null,2)}
|
|
69
|
+
`),f&&f();return}let e=await m({session:p,command:s,positionals:i,flags:d});if(e.ok){if(d.json){c({success:!0,data:e.data??{}}),f&&f();return}if("snapshot"===s){process.stdout.write(function(e,t={}){let r=e.nodes??[],n=!!e.truncated,s="string"==typeof e.appName?e.appName:void 0,o="string"==typeof e.appBundleId?e.appBundleId:void 0,a=[];s&&a.push(`Page: ${s}`),o&&a.push(`App: ${o}`);let i=`Snapshot: ${r.length} nodes${n?" (truncated)":""}`,l=a.length>0?`${a.join("\n")}
|
|
71
70
|
`:"";if(!Array.isArray(r)||0===r.length)return`${l}${i}
|
|
72
71
|
`;if(t.raw){let e=r.map(e=>JSON.stringify(e));return`${l}${i}
|
|
73
72
|
${e.join("\n")}
|
|
74
|
-
`}let c=[],
|
|
75
|
-
${
|
|
76
|
-
`}(e.data??{},{raw:
|
|
73
|
+
`}let c=[],u=[];for(let e of r){let t=e.depth??0;for(;c.length>0&&t<=c[c.length-1];)c.pop();let r=e.label?.trim()||e.value?.trim()||e.identifier?.trim()||"",n=function(e){let t=e.replace(/XCUIElementType/gi,"").toLowerCase();switch(t.startsWith("ax")&&(t=t.replace(/^ax/,"")),t){case"application":return"application";case"navigationbar":return"navigation-bar";case"tabbar":return"tab-bar";case"button":return"button";case"link":return"link";case"cell":return"cell";case"statictext":case"statictext":return"text";case"textfield":case"textfield":return"text-field";case"textview":case"textarea":return"text-view";case"switch":return"switch";case"slider":return"slider";case"image":return"image";case"table":return"list";case"collectionview":return"collection";case"searchfield":return"search";case"segmentedcontrol":return"segmented-control";case"group":return"group";case"window":return"window";case"checkbox":return"checkbox";case"radio":return"radio";case"menuitem":return"menu-item";case"toolbar":return"toolbar";case"scrollarea":return"scroll-area";case"table":return"table";default:return t||"element"}}(e.type??"Element"),s="group"===n&&!r;s&&c.push(t);let o=s?t:Math.max(0,t-c.length),a=" ".repeat(o),i=e.ref?`@${e.ref}`:"",l=[!1===e.enabled?"disabled":null].filter(Boolean).join(", "),d=l?` [${l}]`:"",p=r?` "${r}"`:"";if(s){u.push(`${a}${i} [${n}]${d}`.trimEnd());continue}u.push(`${a}${i} [${n}]${p}${d}`.trimEnd())}return`${l}${i}
|
|
74
|
+
${u.join("\n")}
|
|
75
|
+
`}(e.data??{},{raw:d.snapshotRaw})),f&&f();return}if("get"===s){let t=i[0];if("text"===t){let t=e.data?.text??"";process.stdout.write(`${t}
|
|
77
76
|
`),f&&f();return}if("attrs"===t){let t=e.data?.node??{};process.stdout.write(`${JSON.stringify(t,null,2)}
|
|
78
77
|
`),f&&f();return}}if("find"===s){let t=e.data;if("string"==typeof t?.text){process.stdout.write(`${t.text}
|
|
79
78
|
`),f&&f();return}if("boolean"==typeof t?.found){process.stdout.write(`Found: ${t.found}
|
|
@@ -86,7 +85,7 @@ ${d.join("\n")}
|
|
|
86
85
|
`),s&&process.stdout.write(`Source: ${s}
|
|
87
86
|
`),f&&f();return}if("android"===e){process.stdout.write(`Foreground app: ${o??"unknown"}
|
|
88
87
|
`),a&&process.stdout.write(`Activity: ${a}
|
|
89
|
-
`),f&&f();return}}}f&&f();return}throw new l(e.error.code,e.error.message,e.error.details)}catch(t){let e=r(t);if(
|
|
88
|
+
`),f&&f();return}}}f&&f();return}throw new l(e.error.code,e.error.message,e.error.details)}catch(t){let e=r(t);if(d.json)c({success:!1,error:{code:e.code,message:e.message,details:e.details}});else if(u(e),d.verbose)try{let e=await import("node:fs"),t=await import("node:os"),r=(await import("node:path")).join(t.homedir(),".agent-device","daemon.log");if(e.existsSync(r)){let t=e.readFileSync(r,"utf8").split("\n"),n=t.slice(Math.max(0,t.length-200)).join("\n");n.trim().length>0&&process.stderr.write(`
|
|
90
89
|
[daemon log]
|
|
91
90
|
${n}
|
|
92
|
-
`)}}catch{}f&&f(),process.exit(1)}}n(process.argv[1]??"").href===import.meta.url&&$(process.argv.slice(2)).catch(e=>{
|
|
91
|
+
`)}}catch{}f&&f(),process.exit(1)}}n(process.argv[1]??"").href===import.meta.url&&$(process.argv.slice(2)).catch(e=>{u(r(e)),process.exit(1)}),$(process.argv.slice(2));
|