lynkeus 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.
Files changed (217) hide show
  1. package/lib/__tests__/args.test.d.ts +1 -0
  2. package/lib/__tests__/args.test.js +69 -0
  3. package/lib/__tests__/check.test.d.ts +1 -0
  4. package/lib/__tests__/check.test.js +60 -0
  5. package/lib/__tests__/init.test.d.ts +1 -0
  6. package/lib/__tests__/init.test.js +42 -0
  7. package/lib/__tests__/record.test.d.ts +1 -0
  8. package/lib/__tests__/record.test.js +44 -0
  9. package/lib/__tests__/snapshot.test.d.ts +1 -0
  10. package/lib/__tests__/snapshot.test.js +47 -0
  11. package/lib/__tests__/targets.test.d.ts +1 -0
  12. package/lib/__tests__/targets.test.js +127 -0
  13. package/lib/args.d.ts +30 -0
  14. package/lib/args.js +86 -0
  15. package/lib/cases/__tests__/format.test.d.ts +1 -0
  16. package/lib/cases/__tests__/format.test.js +61 -0
  17. package/lib/cases/__tests__/runner.test.d.ts +1 -0
  18. package/lib/cases/__tests__/runner.test.js +188 -0
  19. package/lib/cases/fixtures.d.ts +13 -0
  20. package/lib/cases/fixtures.js +100 -0
  21. package/lib/cases/format.d.ts +37 -0
  22. package/lib/cases/format.js +169 -0
  23. package/lib/cases/runner.d.ts +61 -0
  24. package/lib/cases/runner.js +590 -0
  25. package/lib/cases/templates.d.ts +20 -0
  26. package/lib/cases/templates.js +156 -0
  27. package/lib/cli.d.ts +6 -0
  28. package/lib/cli.js +173 -0
  29. package/lib/commands/cases.d.ts +1 -0
  30. package/lib/commands/cases.js +179 -0
  31. package/lib/commands/check.d.ts +36 -0
  32. package/lib/commands/check.js +67 -0
  33. package/lib/commands/crawl/return.d.ts +20 -0
  34. package/lib/commands/crawl/return.js +125 -0
  35. package/lib/commands/crawl/state.d.ts +30 -0
  36. package/lib/commands/crawl/state.js +56 -0
  37. package/lib/commands/crawl.d.ts +67 -0
  38. package/lib/commands/crawl.js +213 -0
  39. package/lib/commands/device.d.ts +10 -0
  40. package/lib/commands/device.js +122 -0
  41. package/lib/commands/doctor.d.ts +3 -0
  42. package/lib/commands/doctor.js +240 -0
  43. package/lib/commands/drive.d.ts +1 -0
  44. package/lib/commands/drive.js +410 -0
  45. package/lib/commands/flake.d.ts +14 -0
  46. package/lib/commands/flake.js +42 -0
  47. package/lib/commands/headless.d.ts +1 -0
  48. package/lib/commands/headless.js +127 -0
  49. package/lib/commands/init.d.ts +13 -0
  50. package/lib/commands/init.js +85 -0
  51. package/lib/commands/map.d.ts +2 -0
  52. package/lib/commands/map.js +297 -0
  53. package/lib/commands/os.d.ts +1 -0
  54. package/lib/commands/os.js +193 -0
  55. package/lib/commands/overlay.d.ts +9 -0
  56. package/lib/commands/overlay.js +12 -0
  57. package/lib/commands/record.d.ts +5 -0
  58. package/lib/commands/record.js +55 -0
  59. package/lib/commands/runs.d.ts +1 -0
  60. package/lib/commands/runs.js +452 -0
  61. package/lib/commands/smoke.d.ts +28 -0
  62. package/lib/commands/smoke.js +59 -0
  63. package/lib/commands/snapshot.d.ts +27 -0
  64. package/lib/commands/snapshot.js +69 -0
  65. package/lib/config.d.ts +72 -0
  66. package/lib/config.js +44 -0
  67. package/lib/device/devices.d.ts +11 -0
  68. package/lib/device/devices.js +33 -0
  69. package/lib/device/driver.d.ts +53 -0
  70. package/lib/device/driver.js +169 -0
  71. package/lib/device/platform.d.ts +7 -0
  72. package/lib/device/platform.js +59 -0
  73. package/lib/files.d.ts +7 -0
  74. package/lib/files.js +29 -0
  75. package/lib/flow/artifacts.d.ts +19 -0
  76. package/lib/flow/artifacts.js +49 -0
  77. package/lib/flow/runner.d.ts +13 -0
  78. package/lib/flow/runner.js +169 -0
  79. package/lib/flow/types.d.ts +111 -0
  80. package/lib/flow/types.js +1 -0
  81. package/lib/index.d.ts +18 -0
  82. package/lib/index.js +11 -0
  83. package/lib/knowledge/__tests__/edges.test.d.ts +1 -0
  84. package/lib/knowledge/__tests__/edges.test.js +25 -0
  85. package/lib/knowledge/__tests__/events.test.d.ts +1 -0
  86. package/lib/knowledge/__tests__/events.test.js +39 -0
  87. package/lib/knowledge/__tests__/extract.test.d.ts +1 -0
  88. package/lib/knowledge/__tests__/extract.test.js +69 -0
  89. package/lib/knowledge/__tests__/frontier.test.d.ts +1 -0
  90. package/lib/knowledge/__tests__/frontier.test.js +57 -0
  91. package/lib/knowledge/__tests__/funnels.test.d.ts +1 -0
  92. package/lib/knowledge/__tests__/funnels.test.js +40 -0
  93. package/lib/knowledge/__tests__/lint.test.d.ts +1 -0
  94. package/lib/knowledge/__tests__/lint.test.js +53 -0
  95. package/lib/knowledge/__tests__/perf.test.d.ts +1 -0
  96. package/lib/knowledge/__tests__/perf.test.js +34 -0
  97. package/lib/knowledge/__tests__/plan.test.d.ts +1 -0
  98. package/lib/knowledge/__tests__/plan.test.js +35 -0
  99. package/lib/knowledge/__tests__/query.test.d.ts +1 -0
  100. package/lib/knowledge/__tests__/query.test.js +55 -0
  101. package/lib/knowledge/__tests__/screens.test.d.ts +1 -0
  102. package/lib/knowledge/__tests__/screens.test.js +30 -0
  103. package/lib/knowledge/__tests__/why.test.d.ts +1 -0
  104. package/lib/knowledge/__tests__/why.test.js +28 -0
  105. package/lib/knowledge/edges.d.ts +30 -0
  106. package/lib/knowledge/edges.js +68 -0
  107. package/lib/knowledge/errors.d.ts +8 -0
  108. package/lib/knowledge/errors.js +62 -0
  109. package/lib/knowledge/events.d.ts +23 -0
  110. package/lib/knowledge/events.js +51 -0
  111. package/lib/knowledge/extract.d.ts +26 -0
  112. package/lib/knowledge/extract.js +365 -0
  113. package/lib/knowledge/frontier.d.ts +26 -0
  114. package/lib/knowledge/frontier.js +60 -0
  115. package/lib/knowledge/funnels.d.ts +46 -0
  116. package/lib/knowledge/funnels.js +103 -0
  117. package/lib/knowledge/lint.d.ts +16 -0
  118. package/lib/knowledge/lint.js +89 -0
  119. package/lib/knowledge/perf.d.ts +32 -0
  120. package/lib/knowledge/perf.js +91 -0
  121. package/lib/knowledge/plan.d.ts +22 -0
  122. package/lib/knowledge/plan.js +81 -0
  123. package/lib/knowledge/problems.d.ts +5 -0
  124. package/lib/knowledge/problems.js +8 -0
  125. package/lib/knowledge/query.d.ts +36 -0
  126. package/lib/knowledge/query.js +127 -0
  127. package/lib/knowledge/route-params.d.ts +6 -0
  128. package/lib/knowledge/route-params.js +42 -0
  129. package/lib/knowledge/screens.d.ts +30 -0
  130. package/lib/knowledge/screens.js +125 -0
  131. package/lib/knowledge/tree.d.ts +15 -0
  132. package/lib/knowledge/tree.js +63 -0
  133. package/lib/knowledge/types.d.ts +62 -0
  134. package/lib/knowledge/types.js +6 -0
  135. package/lib/knowledge/why.d.ts +24 -0
  136. package/lib/knowledge/why.js +153 -0
  137. package/lib/mcp/server.d.ts +4 -0
  138. package/lib/mcp/server.js +111 -0
  139. package/lib/registry.d.ts +78 -0
  140. package/lib/registry.js +113 -0
  141. package/lib/runtime.d.ts +12 -0
  142. package/lib/runtime.js +56 -0
  143. package/lib/targets.d.ts +17 -0
  144. package/lib/targets.js +43 -0
  145. package/package.json +47 -0
  146. package/src/__tests__/args.test.ts +82 -0
  147. package/src/__tests__/check.test.ts +71 -0
  148. package/src/__tests__/init.test.ts +52 -0
  149. package/src/__tests__/record.test.ts +49 -0
  150. package/src/__tests__/snapshot.test.ts +51 -0
  151. package/src/__tests__/targets.test.ts +132 -0
  152. package/src/args.ts +111 -0
  153. package/src/cases/__tests__/format.test.ts +76 -0
  154. package/src/cases/__tests__/runner.test.ts +230 -0
  155. package/src/cases/fixtures.ts +104 -0
  156. package/src/cases/format.ts +183 -0
  157. package/src/cases/runner.ts +575 -0
  158. package/src/cases/templates.ts +159 -0
  159. package/src/cli.ts +176 -0
  160. package/src/commands/cases.ts +170 -0
  161. package/src/commands/check.ts +83 -0
  162. package/src/commands/crawl/return.ts +130 -0
  163. package/src/commands/crawl/state.ts +70 -0
  164. package/src/commands/crawl.ts +262 -0
  165. package/src/commands/device.ts +119 -0
  166. package/src/commands/doctor.ts +237 -0
  167. package/src/commands/drive.ts +413 -0
  168. package/src/commands/flake.ts +48 -0
  169. package/src/commands/headless.ts +131 -0
  170. package/src/commands/init.ts +98 -0
  171. package/src/commands/map.ts +298 -0
  172. package/src/commands/os.ts +195 -0
  173. package/src/commands/overlay.ts +20 -0
  174. package/src/commands/record.ts +46 -0
  175. package/src/commands/runs.ts +475 -0
  176. package/src/commands/smoke.ts +86 -0
  177. package/src/commands/snapshot.ts +87 -0
  178. package/src/config.ts +103 -0
  179. package/src/device/devices.ts +44 -0
  180. package/src/device/driver.ts +197 -0
  181. package/src/device/platform.ts +74 -0
  182. package/src/files.ts +35 -0
  183. package/src/flow/artifacts.ts +61 -0
  184. package/src/flow/runner.ts +182 -0
  185. package/src/flow/types.ts +55 -0
  186. package/src/index.ts +18 -0
  187. package/src/knowledge/__tests__/edges.test.ts +34 -0
  188. package/src/knowledge/__tests__/events.test.ts +45 -0
  189. package/src/knowledge/__tests__/extract.test.ts +88 -0
  190. package/src/knowledge/__tests__/frontier.test.ts +77 -0
  191. package/src/knowledge/__tests__/funnels.test.ts +58 -0
  192. package/src/knowledge/__tests__/lint.test.ts +66 -0
  193. package/src/knowledge/__tests__/perf.test.ts +41 -0
  194. package/src/knowledge/__tests__/plan.test.ts +52 -0
  195. package/src/knowledge/__tests__/query.test.ts +74 -0
  196. package/src/knowledge/__tests__/screens.test.ts +37 -0
  197. package/src/knowledge/__tests__/why.test.ts +40 -0
  198. package/src/knowledge/edges.ts +91 -0
  199. package/src/knowledge/errors.ts +73 -0
  200. package/src/knowledge/events.ts +60 -0
  201. package/src/knowledge/extract.ts +379 -0
  202. package/src/knowledge/frontier.ts +82 -0
  203. package/src/knowledge/funnels.ts +126 -0
  204. package/src/knowledge/lint.ts +99 -0
  205. package/src/knowledge/perf.ts +114 -0
  206. package/src/knowledge/plan.ts +95 -0
  207. package/src/knowledge/problems.ts +12 -0
  208. package/src/knowledge/query.ts +146 -0
  209. package/src/knowledge/route-params.ts +39 -0
  210. package/src/knowledge/screens.ts +139 -0
  211. package/src/knowledge/tree.ts +66 -0
  212. package/src/knowledge/types.ts +60 -0
  213. package/src/knowledge/why.ts +189 -0
  214. package/src/mcp/server.ts +109 -0
  215. package/src/registry.ts +191 -0
  216. package/src/runtime.ts +54 -0
  217. package/src/targets.ts +44 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The screen, the last requests and the trace after a failure, read the way a
3
+ * person would: a dead navigator, a gone session, a 5xx, a covered target, a
4
+ * wording difference, a screen still loading. Each rule names what to do next.
5
+ */
6
+ import type { Element, RequestRecord, Screen, TraceEvent } from 'lynkeus-protocol';
7
+
8
+ import { pathOf } from './problems.js';
9
+
10
+ export type Evidence = {
11
+ screen: Screen;
12
+ requests: RequestRecord[];
13
+ inFlight: number;
14
+ trace?: TraceEvent[];
15
+ /** What the failed command was looking for. */
16
+ target?: { testId?: string; text?: string; route?: string };
17
+ };
18
+
19
+ export type Finding = { rule: string; message: string };
20
+
21
+ const fold = (t: string): string =>
22
+ t
23
+ .normalize('NFKD')
24
+ .replace(/[\u0300-\u036f]/g, '')
25
+ .toLowerCase()
26
+ .trim();
27
+
28
+ const distance = (a: string, b: string): number => {
29
+ const m = a.length;
30
+ const n = b.length;
31
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
32
+ for (let i = 1; i <= m; i++) {
33
+ const cur = [i];
34
+ for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1));
35
+ prev = cur;
36
+ }
37
+ return prev[n]!;
38
+ };
39
+ const similar = (a: string, b: string): boolean => {
40
+ if (a.includes(b) || b.includes(a)) return true;
41
+ if (a.length > 200 || b.length > 200) return false;
42
+ return distance(a, b) <= Math.max(2, Math.floor(Math.max(a.length, b.length) * 0.25));
43
+ };
44
+
45
+ export const explain = (e: Evidence): Finding[] => {
46
+ const out: Finding[] = [];
47
+ const els = e.screen.elements;
48
+ const done = e.requests.filter((r) => r.status !== undefined || r.error);
49
+ const status = (n: number) => done.filter((r) => r.status === n);
50
+
51
+ if (!e.screen.route && els.length === 0) {
52
+ const crash = e.trace
53
+ ?.slice()
54
+ .reverse()
55
+ .find((t) => t.kind === 'crash');
56
+ out.push(
57
+ crash && crash.kind === 'crash'
58
+ ? {
59
+ rule: 'dead-navigator',
60
+ message: `the screen is empty and has no route: a component threw and took the tree with it — ${crash.message}${crash.stack ? `\n ${crash.stack.split('\n').slice(1, 4).join('\n ')}` : ''}`,
61
+ }
62
+ : {
63
+ rule: 'dead-navigator',
64
+ message: 'the screen is empty and has no route: a component threw while rendering. Reset the app and look for the render error in the host log.',
65
+ },
66
+ );
67
+ }
68
+
69
+ if (done.length && done.every((r) => r.status === 401))
70
+ out.push({
71
+ rule: 'session-gone',
72
+ message: 'every request answered 401: the session is gone (a logout, a reset, or a key revoked elsewhere). Log in again.',
73
+ });
74
+
75
+ const forbidden = status(403);
76
+ if (forbidden.length >= 2)
77
+ out.push({
78
+ rule: 'forbidden',
79
+ message: `the backend refuses ${[...new Set(forbidden.map((r) => pathOf(r.url)))].slice(0, 3).join(', ')} with 403: an account state or a permission, not the screen. Check what the case expects of this user.`,
80
+ });
81
+
82
+ const five = done.find((r) => (r.status ?? 0) >= 500);
83
+ if (five)
84
+ out.push({
85
+ rule: 'backend-error',
86
+ message: `the backend answered ${five.status} on ${five.method} ${pathOf(five.url)}: a server error, not the app. Reproduce it with curl before touching the case.`,
87
+ });
88
+
89
+ if (status(429).length)
90
+ out.push({
91
+ rule: 'rate-limited',
92
+ message: 'a request answered 429: the backend is rate limiting this client. Space the runs out or raise the limit locally.',
93
+ });
94
+
95
+ const stall = e.trace
96
+ ?.slice()
97
+ .reverse()
98
+ .find((t) => t.kind === 'stall');
99
+ if (stall && stall.kind === 'stall')
100
+ out.push({
101
+ rule: 'render-loop',
102
+ message: `the host broke a render loop${stall.component ? ` in ${stall.component}` : ''} (${stall.renders} updates in ${stall.windowMs}ms): the screen is dead until a reset. A component whose effect and store feed each other.`,
103
+ });
104
+
105
+ if (e.target) {
106
+ const { testId, text, route } = e.target;
107
+ const match = (el: Element) => (testId && el.testId === testId) || (text && (el.text === text || el.accessibilityLabel === text));
108
+ const found = els.find(match);
109
+ if (found?.hidden) {
110
+ out.push({
111
+ rule: 'hidden',
112
+ message: `${testId ? `#${testId}` : JSON.stringify(text)} is in the tree but not presented (${found.hidden}): it belongs to a screen the app is not showing. Close what is open, or target something inside it.`,
113
+ });
114
+ }
115
+ if (found?.covered)
116
+ out.push({
117
+ rule: 'covered',
118
+ message: `${testId ? `#${testId}` : JSON.stringify(text)} is on the screen but covered: a sheet or overlay is over it. Close it, or press what covers it.`,
119
+ });
120
+ if (found && !found.enabled)
121
+ out.push({
122
+ rule: 'disabled',
123
+ message: `${testId ? `#${testId}` : JSON.stringify(text)} is on the screen but disabled: a form is not valid yet, or a request it depends on is in flight.`,
124
+ });
125
+ if (!found && text) {
126
+ const want = fold(text);
127
+ const near = els
128
+ .map((el) => el.text ?? el.accessibilityLabel)
129
+ .filter((t): t is string => !!t)
130
+ .find((t) => similar(fold(t), want));
131
+ if (near && near !== text)
132
+ out.push({ rule: 'near-match', message: `the text is not there, but ${JSON.stringify(near)} is: a wording, accent or case difference?` });
133
+ const listed = els.filter((el) => el.testId && /-\d+-item$/.test(el.testId));
134
+ const prefix = want.slice(0, 4);
135
+ if (listed.length >= 10 && prefix && listed.filter((el) => fold(el.text ?? '').includes(prefix)).length >= listed.length / 2)
136
+ out.push({
137
+ rule: 'capped-list',
138
+ message: `the list shows ${listed.length} near matches and yours is not among them: the data has accumulated look-alikes and the list is capped. Search by something unique, or reset the data.`,
139
+ });
140
+ }
141
+ if (route && e.screen.route && e.screen.route !== route) {
142
+ const recent =
143
+ e.trace
144
+ ?.filter((t) => t.kind === 'route')
145
+ .slice(-3)
146
+ .map((t) => (t.kind === 'route' ? t.route : undefined))
147
+ .filter(Boolean) ?? [];
148
+ if (recent.includes(route))
149
+ out.push({
150
+ rule: 'route-left',
151
+ message: `the app was on ${route} and moved to ${e.screen.route}: something navigated after the step (a redirect, a gate, a modal). The trace shows the order.`,
152
+ });
153
+ }
154
+ }
155
+
156
+ if (e.inFlight > 0)
157
+ out.push({
158
+ rule: 'in-flight',
159
+ message: `${e.inFlight} request(s) still in flight when the command gave up: the screen had not finished loading. A longer timeout, or wait for the request it needs.`,
160
+ });
161
+
162
+ if (done.length === 0 && e.inFlight === 0 && e.target?.route)
163
+ out.push({
164
+ rule: 'no-requests',
165
+ message: 'no request left the app while this ran: nothing was fetched. Is the user logged in, and is the app talking to the backend it should?',
166
+ });
167
+
168
+ // Hosts the sandbox refuses are usually third parties nobody needs: named only when nothing else explains it.
169
+ const refused = done.filter((r) => r.status === 0 && r.error && /networkHosts|is off/.test(r.error));
170
+ if (refused.length && e.target && out.length === 0)
171
+ out.push({
172
+ rule: 'host-refused',
173
+ message: `${refused.length} request(s) were refused by the host (${[...new Set(refused.map((r) => r.url.replace(/^https?:\/\//, '').split('/')[0]))].slice(0, 3).join(', ')}): if the screen needs one of them, allow the host in networkHosts or mock it.`,
174
+ });
175
+
176
+ const lastCommand = e.trace
177
+ ?.slice()
178
+ .reverse()
179
+ .find((t) => t.kind === 'command' && t.error);
180
+ if (lastCommand && lastCommand.kind === 'command' && out.length === 0)
181
+ out.push({ rule: 'last-error', message: `the last command that failed was ${lastCommand.method}: ${lastCommand.error}` });
182
+
183
+ return out;
184
+ };
185
+
186
+ export const describeWhy = (findings: Finding[]): string =>
187
+ findings.length
188
+ ? findings.map((f) => ` • ${f.message}`).join('\n')
189
+ : ' (nothing in the evidence explains it: the screen, requests and trace look ordinary)';
@@ -0,0 +1,109 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
6
+
7
+ import type { FlagSpec } from '../args.js';
8
+ import type { Session } from '../cli.js';
9
+ import { type Command, commands, overMcp } from '../registry.js';
10
+ import { execute } from '../runtime.js';
11
+ import { TARGET_HELP } from '../targets.js';
12
+
13
+ const text = (body: string) => ({ content: [{ type: 'text' as const, text: body }] });
14
+
15
+ type Property = { type: string; items?: { type: string }; description: string };
16
+
17
+ const propertyFor = (f: FlagSpec): Property => ({
18
+ type: f.type === 'list' ? 'array' : f.type,
19
+ ...(f.type === 'list' ? { items: { type: 'string' } } : {}),
20
+ description: f.help + (f.default !== undefined ? ` (default ${f.default})` : ''),
21
+ });
22
+
23
+ const describe = (c: Command) => {
24
+ const details = c.mcpDetails ?? c.details;
25
+ return details ? `${c.summary}\n\n${details}` : c.summary;
26
+ };
27
+
28
+ const identifier = (name: string) => name.replace(/[^a-z0-9]+/gi, '_');
29
+
30
+ // Written by hand: a schema library adds fields to every tool that a model pays for on every turn.
31
+ const schemaFor = (c: Command) => {
32
+ const properties: Record<string, Property> = {};
33
+ const required: string[] = [];
34
+ for (const p of c.positionals ?? []) {
35
+ const key = identifier(p.name);
36
+ properties[key] = p.rest ? { type: 'array', items: { type: 'string' }, description: p.help } : { type: 'string', description: p.help };
37
+ if (p.required) required.push(key);
38
+ }
39
+ for (const [name, f] of Object.entries(c.flags ?? {})) if (!c.mcpOmit?.includes(name)) properties[identifier(name)] = propertyFor(f);
40
+ return { type: 'object' as const, properties, ...(required.length ? { required } : {}) };
41
+ };
42
+
43
+ /** Back to an argv, so the same runtime serves both surfaces. */
44
+ const argvFor = (c: Command, input: Record<string, unknown>): string[] => {
45
+ const av = c.name.split(' ');
46
+ for (const p of c.positionals ?? []) {
47
+ const v = input[identifier(p.name)];
48
+ if (v === undefined) continue;
49
+ if (Array.isArray(v)) av.push(...v.map(String));
50
+ else av.push(String(v));
51
+ }
52
+ for (const [name, f] of Object.entries(c.flags ?? {})) {
53
+ const v = input[identifier(name)];
54
+ if (v === undefined) continue;
55
+ if (f.type === 'boolean') {
56
+ if (v) av.push(`--${name}`);
57
+ } else if (f.type === 'list') for (const item of v as string[]) av.push(`--${name}`, item);
58
+ else av.push(`--${name}`, String(v));
59
+ }
60
+ return av;
61
+ };
62
+
63
+ export const serve = async (session: Session, options: { full?: boolean } = {}) => {
64
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
65
+ const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} } });
66
+ const served = commands().filter((c) => overMcp(c, !!options.full));
67
+ const byName = new Map(served.map((c) => [identifier(c.name), c]));
68
+ // So a model reading the app's screens knows their language before it starts guessing.
69
+ const { config } = session.ctx;
70
+ const locale = config.language || config.locale ? JSON.stringify({ language: config.language, ...config.locale }, null, 2) : undefined;
71
+
72
+ // The grammar of a target is spelled where a model meets it first; after that it is a word.
73
+ let spelled = false;
74
+ const once = (help: string) => {
75
+ if (!help.includes(TARGET_HELP)) return help;
76
+ const out = spelled ? help.replace(TARGET_HELP, 'target') : help;
77
+ spelled = true;
78
+ return out;
79
+ };
80
+ const tools = served.map((c) => {
81
+ const schema = schemaFor(c);
82
+ for (const property of Object.values(schema.properties)) property.description = once(property.description);
83
+ return { name: identifier(c.name), description: describe(c), inputSchema: schema };
84
+ });
85
+
86
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
87
+ tools: [
88
+ ...(locale
89
+ ? [
90
+ {
91
+ name: 'app_locale',
92
+ description: 'The language and region this app is built for, as its own configuration declares them.',
93
+ inputSchema: { type: 'object' as const, properties: {} },
94
+ },
95
+ ]
96
+ : []),
97
+ ...tools,
98
+ ],
99
+ }));
100
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
101
+ if (request.params.name === 'app_locale' && locale) return text(locale);
102
+ const c = byName.get(request.params.name);
103
+ if (!c) return { ...text(`✗ no tool ${request.params.name}`), isError: true };
104
+ const r = await execute(argvFor(c, (request.params.arguments ?? {}) as Record<string, unknown>), session.ctx);
105
+ if (r.error) return { ...text(`✗ ${r.error}${r.why ? `\n\nwhy:\n${r.why}` : ''}`), isError: true };
106
+ return text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
107
+ });
108
+ await server.connect(new StdioServerTransport());
109
+ };
@@ -0,0 +1,191 @@
1
+ /**
2
+ * One definition per command, read by every surface: the terminal's parser and
3
+ * usage, session lines, the MCP tools and the reference document.
4
+ */
5
+ import type { LynkeusConfig } from './config.js';
6
+ import type { Device } from './device/driver.js';
7
+ import type { ScreenGraph } from './knowledge/types.js';
8
+ import { GLOBAL_FLAGS, parseArgs, type FlagSpec, type FlagValues, type PositionalSpec } from './args.js';
9
+
10
+ export type Group = 'drive' | 'os' | 'map' | 'runs' | 'headless';
11
+
12
+ const GROUPS: Record<Group, { title: string; blurb: string }> = {
13
+ drive: { title: 'Drive', blurb: 'the app that is running, through the agent inside it' },
14
+ os: { title: 'os', blurb: 'what the outside world hands the app: links, clock, sensors, the network, the simulator' },
15
+ map: { title: 'map', blurb: "what lynkeus knows before touching anything, from the app's source and from walks" },
16
+ runs: { title: 'Runs', blurb: 'flows, smokes, crawls and recordings' },
17
+ headless: { title: 'headless', blurb: 'the app hosted in Node under jest, no simulator' },
18
+ };
19
+
20
+ /** What a command runs against: one per process, or per session or MCP server. */
21
+ export type Base = {
22
+ root: string;
23
+ config: LynkeusConfig;
24
+ /** The live app, connected on first use. */
25
+ device: () => Promise<Device>;
26
+ /** The static graph; throws with advice when there is none. */
27
+ graph: () => ScreenGraph;
28
+ /** Progress and diagnostics, on stderr. */
29
+ err: (line: string) => void;
30
+ /** Inside `lynkeus session` and over MCP: one connection, many commands. */
31
+ attached: boolean;
32
+ };
33
+
34
+ export type Ctx = Base & { args: string[]; flags: FlagValues; passthrough: string[] };
35
+
36
+ export type Result = {
37
+ /** What a person reads; the JSON on one line when left out. */
38
+ text?: string;
39
+ /** What a program reads; printed instead of the text under --json. */
40
+ json?: unknown;
41
+ code?: number;
42
+ };
43
+
44
+ export type Command = {
45
+ /** `press`, or `map build` for a grouped command. */
46
+ name: string;
47
+ group: Group;
48
+ summary: string;
49
+ /** Longer help, for `lynkeus help <command>` and the reference. */
50
+ details?: string;
51
+ positionals?: PositionalSpec[];
52
+ flags?: Record<string, FlagSpec>;
53
+ /** Other spellings it answers to, left out of the usage. */
54
+ aliases?: string[];
55
+ needs: 'nothing' | 'graph' | 'app';
56
+ /** `core` is served to every MCP client; other commands that need the app or the graph only with `mcp --full`. */
57
+ mcp?: boolean | 'core';
58
+ /** Flags a model has no use for (artifacts, timing knobs): left out of the MCP schema. */
59
+ mcpOmit?: string[];
60
+ /** What a model is told instead of `details`. */
61
+ mcpDetails?: string;
62
+ /** Runnable from a session line (default: when it needs the app or the graph). */
63
+ session?: boolean;
64
+ /** The usage line, when the generated one would mislead. */
65
+ example?: string;
66
+ run: (ctx: Ctx) => Promise<Result | string | undefined>;
67
+ };
68
+
69
+ export const define = (c: Command): Command => c;
70
+
71
+ const byName = new Map<string, Command>();
72
+ const all: Command[] = [];
73
+
74
+ export const register = (commands: Command[]): void => {
75
+ for (const c of commands) {
76
+ for (const name of [c.name, ...(c.aliases ?? [])]) {
77
+ if (byName.has(name)) throw new Error(`${name} is defined twice`);
78
+ byName.set(name, c);
79
+ }
80
+ all.push(c);
81
+ }
82
+ };
83
+
84
+ export const commands = (): Command[] => all;
85
+ export const find = (name: string): Command | undefined => byName.get(name);
86
+
87
+ /** The command an argv names, and the rest of the argv. */
88
+ export const resolve = (argv: string[]): { command: Command; rest: string[] } | undefined => {
89
+ const two = argv.length >= 2 ? byName.get(argv.slice(0, 2).join(' ')) : undefined;
90
+ if (two) return { command: two, rest: argv.slice(2) };
91
+ const one = argv[0] ? byName.get(argv[0]) : undefined;
92
+ return one ? { command: one, rest: argv.slice(1) } : undefined;
93
+ };
94
+
95
+ export const parseFor = (command: Command, rest: string[]) => parseArgs(rest, command.positionals, command.flags);
96
+
97
+ export const inSession = (c: Command): boolean => c.session ?? c.needs !== 'nothing';
98
+
99
+ export const overMcp = (c: Command, full: boolean): boolean => {
100
+ if (c.mcp === false || (c.needs === 'nothing' && c.mcp === undefined)) return false;
101
+ return c.mcp === 'core' || full;
102
+ };
103
+
104
+ const flagSignature = (name: string, f: FlagSpec) => `--${name}${f.type === 'boolean' ? '' : ` <${f.value ?? f.type}>`}`;
105
+
106
+ const signature = (c: Command): string => {
107
+ const pos = (c.positionals ?? []).map((p) => (p.required ? `<${p.name}>` : `[${p.name}]`) + (p.rest ? '…' : '')).join(' ');
108
+ const fl = Object.entries(c.flags ?? {})
109
+ .map(([n, f]) => `[${flagSignature(n, f)}]`)
110
+ .join(' ');
111
+ return [c.name, pos, fl].filter(Boolean).join(' ');
112
+ };
113
+
114
+ const inGroups = () => (Object.keys(GROUPS) as Group[]).map((g) => ({ ...GROUPS[g], list: all.filter((c) => c.group === g) })).filter((g) => g.list.length);
115
+
116
+ export const usage = (): string => {
117
+ const lines = ['lynkeus — drive a React Native app from the outside, through the agent inside it', ''];
118
+ for (const g of inGroups()) {
119
+ lines.push(`${g.title} — ${g.blurb}`);
120
+ for (const c of g.list) {
121
+ const sig = c.example ?? signature(c);
122
+ lines.push(sig.length > 44 ? ` ${sig}\n${' '.repeat(47)}${c.summary}` : ` ${sig.padEnd(45)}${c.summary}`);
123
+ }
124
+ lines.push('');
125
+ }
126
+ lines.push(
127
+ `Global: ${Object.entries(GLOBAL_FLAGS)
128
+ .map(([n, f]) => flagSignature(n, f))
129
+ .join(' ')}`,
130
+ '`lynkeus help <command>` for details; `lynkeus session` runs many commands on one connection; `lynkeus mcp` serves the core of them to an AI harness (`--full`: all).',
131
+ );
132
+ return lines.join('\n');
133
+ };
134
+
135
+ export const helpFor = (c: Command): string => {
136
+ const lines = [`lynkeus ${signature(c)}`, '', c.summary];
137
+ if (c.details) lines.push('', c.details);
138
+ if (c.positionals?.length) {
139
+ lines.push('', 'Arguments');
140
+ for (const p of c.positionals) lines.push(` ${p.name.padEnd(14)} ${p.help}${p.required ? '' : ' (optional)'}`);
141
+ }
142
+ const flags = Object.entries(c.flags ?? {});
143
+ if (flags.length) {
144
+ lines.push('', 'Flags');
145
+ for (const [n, f] of flags) lines.push(` ${flagSignature(n, f).padEnd(24)} ${f.help}${f.default !== undefined ? ` (default ${f.default})` : ''}`);
146
+ }
147
+ if (c.aliases?.length) lines.push('', `Also: ${c.aliases.join(', ')}`);
148
+ const needs = c.needs === 'app' ? 'the app running with the agent' : c.needs === 'graph' ? 'a graph (lynkeus map build)' : 'nothing';
149
+ const mcp = c.mcp === 'core' ? '; served over MCP' : overMcp(c, true) ? '; served over MCP with --full' : '';
150
+ lines.push('', `Needs: ${needs}${inSession(c) ? '; available in a session' : ''}${mcp}`);
151
+ return lines.join('\n');
152
+ };
153
+
154
+ const flagRow = (n: string, f: FlagSpec) => `| \`${flagSignature(n, f)}\` | ${f.help}${f.default !== undefined ? ` (default \`${f.default}\`)` : ''} |`;
155
+
156
+ /** The reference document, generated so it cannot drift from the commands. */
157
+ export const referenceMarkdown = (): string => {
158
+ const lines = [
159
+ '# lynkeus command reference',
160
+ '',
161
+ 'Generated from the command registry (`lynkeus help --markdown`). Every command accepts the global flags at the end.',
162
+ '',
163
+ ];
164
+ for (const g of inGroups()) {
165
+ lines.push(`## ${g.title}`, '', `${g.blurb}.`, '');
166
+ for (const c of g.list) {
167
+ lines.push(`### \`lynkeus ${signature(c)}\``, '', c.summary + (c.details ? `\n\n${c.details}` : ''), '');
168
+ if (c.positionals?.length) {
169
+ lines.push('| Argument | |', '|---|---|');
170
+ for (const p of c.positionals) lines.push(`| \`${p.name}\` | ${p.help}${p.required ? '' : ' (optional)'} |`);
171
+ lines.push('');
172
+ }
173
+ const flags = Object.entries(c.flags ?? {});
174
+ if (flags.length) lines.push('| Flag | |', '|---|---|', ...flags.map(([n, f]) => flagRow(n, f)), '');
175
+ if (c.aliases?.length) lines.push(`Also accepted: ${c.aliases.map((a) => `\`${a}\``).join(', ')}.`, '');
176
+ }
177
+ }
178
+ lines.push(
179
+ '## Modes',
180
+ '',
181
+ '`lynkeus session` keeps one connection and reads JSON arrays of arguments on stdin, one command per line, answering one JSON object per line (`{ok, out, json?}` or `{ok:false, error, why?}`); `exit` ends it. `lynkeus mcp` serves the core commands as MCP tools over stdio; `lynkeus mcp --full` serves all of them. `lynkeus help <command>` prints one command; `lynkeus help --markdown` prints this document.',
182
+ '',
183
+ '## Global flags',
184
+ '',
185
+ '| Flag | |',
186
+ '|---|---|',
187
+ ...Object.entries(GLOBAL_FLAGS).map(([n, f]) => flagRow(n, f)),
188
+ '',
189
+ );
190
+ return lines.join('\n');
191
+ };
package/src/runtime.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Runs one command from an argv: resolve, parse, run, and on failure read the
3
+ * evidence. The terminal, session lines, a recording's stdin, cases and the
4
+ * MCP server all come through here.
5
+ */
6
+ import { UsageError } from './args.js';
7
+ import { describeWhy, explain } from './knowledge/why.js';
8
+ import { type Base, helpFor, parseFor, resolve } from './registry.js';
9
+ import { type Expected, expectedOf } from './targets.js';
10
+
11
+ export type Outcome = { text?: string; json?: unknown; code: number; error?: string; why?: string };
12
+
13
+ /** The screen, requests and trace after a failure, read as rules; empty when there is nothing to read. */
14
+ export const whyFor = async (base: Base, expected?: Expected): Promise<string> => {
15
+ try {
16
+ const device = await base.device();
17
+ const [screen, requests, trace] = await Promise.all([device.screen(), device.requests(), device.trace()]);
18
+ return describeWhy(
19
+ explain({ screen, requests: requests.requests.slice(-40), inFlight: requests.inFlight, trace: trace.events.slice(-40), target: expected }),
20
+ );
21
+ } catch {
22
+ return '';
23
+ }
24
+ };
25
+
26
+ const TARGET_ARG: Record<string, number> = { press: 0, see: 0, wait: 0, swipe: 1, nav: 0, type: 1 };
27
+
28
+ export const execute = async (argv: string[], base: Base): Promise<Outcome> => {
29
+ const found = resolve(argv);
30
+ if (!found) return { code: 2, error: `unknown command "${argv.slice(0, 2).join(' ')}"; \`lynkeus help\` lists them` };
31
+ const { command, rest } = found;
32
+ let parsed: ReturnType<typeof parseFor>;
33
+ try {
34
+ parsed = parseFor(command, rest);
35
+ } catch (error) {
36
+ if (error instanceof UsageError) return { code: 2, error: `${error.message}\n\n${helpFor(command)}` };
37
+ throw error;
38
+ }
39
+ if (parsed.flags.help) return { code: 0, text: helpFor(command) };
40
+ try {
41
+ const result = await command.run({ ...base, ...parsed });
42
+ if (result === undefined) return { code: 0 };
43
+ if (typeof result === 'string') return { code: 0, text: result };
44
+ return { code: result.code ?? 0, text: result.text ?? (result.json === undefined ? undefined : JSON.stringify(result.json)), json: result.json };
45
+ } catch (error) {
46
+ const out: Outcome = { code: 1, error: error instanceof Error ? error.message : String(error) };
47
+ const at = TARGET_ARG[command.name];
48
+ if (at !== undefined) {
49
+ const why = await whyFor(base, expectedOf(parsed.args[at]));
50
+ if (why) out.why = why;
51
+ }
52
+ return out;
53
+ }
54
+ };
package/src/targets.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { Target } from 'lynkeus-protocol';
2
+
3
+ export const TARGET_HELP = '#testId | "text" | 12 (index) | 100,200';
4
+
5
+ /** `#testId`, `12` (an index), `100,200` (a point), anything else a text; `#testId[2]` or `text[2]` is the third match. */
6
+ export const parseTarget = (raw: string): Target => {
7
+ const nth = /^(.+)\[(\d+)\]$/.exec(raw);
8
+ if (nth && !/^\d+$/.test(nth[1]!)) {
9
+ const base = parseTarget(nth[1]!);
10
+ if ('testId' in base || 'text' in base) return { ...base, nth: Number(nth[2]) };
11
+ }
12
+ if (raw.startsWith('#')) return { testId: raw.slice(1) };
13
+ if (/^\d+,\d+$/.test(raw)) {
14
+ const [x, y] = raw.split(',').map(Number);
15
+ return { x: x!, y: y! };
16
+ }
17
+ if (/^\d+$/.test(raw)) return { i: Number(raw) };
18
+ return { text: raw };
19
+ };
20
+
21
+ /** A target narrowed to the match nearest another (`--in`) and to a position among the matches (`--nth`). */
22
+ export const scopedTarget = (raw: string, flags: { in?: unknown; nth?: unknown }): Target => {
23
+ const target = parseTarget(raw);
24
+ if (!('testId' in target) && !('text' in target)) return target;
25
+ const scope = typeof flags.in === 'string' ? parseTarget(flags.in) : undefined;
26
+ if (scope && !('testId' in scope) && !('text' in scope)) throw new Error('--in takes a #testId or a text');
27
+ return {
28
+ ...target,
29
+ ...(scope ? { within: 'testId' in scope ? { testId: scope.testId } : { text: scope.text } } : {}),
30
+ ...(typeof flags.nth === 'number' ? { nth: flags.nth } : {}),
31
+ };
32
+ };
33
+
34
+ export const isRoute = (raw: string) => /^[A-Z]\w*(\.[A-Z]\w*)*$/.test(raw);
35
+
36
+ export type Expected = { testId?: string; text?: string; route?: string };
37
+
38
+ /** What a command was looking for, as `why` reads it. */
39
+ export const expectedOf = (raw: string | undefined): Expected | undefined => {
40
+ if (!raw) return undefined;
41
+ if (isRoute(raw)) return { route: raw };
42
+ const t = parseTarget(raw);
43
+ return 'testId' in t ? { testId: t.testId } : 'text' in t ? { text: t.text } : undefined;
44
+ };