@equinor/fusion-framework-cli-plugin-copilot 1.0.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 (186) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -0
  4. package/dist/esm/commands/app/command.js +176 -0
  5. package/dist/esm/commands/app/command.js.map +1 -0
  6. package/dist/esm/commands/app/eval.js +92 -0
  7. package/dist/esm/commands/app/eval.js.map +1 -0
  8. package/dist/esm/commands/app/format.js +93 -0
  9. package/dist/esm/commands/app/format.js.map +1 -0
  10. package/dist/esm/commands/app/login.js +71 -0
  11. package/dist/esm/commands/app/login.js.map +1 -0
  12. package/dist/esm/commands/app/prompts/execute-step.prompt.js +44 -0
  13. package/dist/esm/commands/app/prompts/execute-step.prompt.js.map +1 -0
  14. package/dist/esm/commands/app/prompts/judge.prompt.js +116 -0
  15. package/dist/esm/commands/app/prompts/judge.prompt.js.map +1 -0
  16. package/dist/esm/commands/app/prompts/plan.prompt.js +47 -0
  17. package/dist/esm/commands/app/prompts/plan.prompt.js.map +1 -0
  18. package/dist/esm/commands/app/prompts/step.prompt.js +42 -0
  19. package/dist/esm/commands/app/prompts/step.prompt.js.map +1 -0
  20. package/dist/esm/commands/app/prompts/system.dev-server.prompt.js +21 -0
  21. package/dist/esm/commands/app/prompts/system.dev-server.prompt.js.map +1 -0
  22. package/dist/esm/commands/app/prompts/system.prompt.js +77 -0
  23. package/dist/esm/commands/app/prompts/system.prompt.js.map +1 -0
  24. package/dist/esm/commands/app/server.js +83 -0
  25. package/dist/esm/commands/app/server.js.map +1 -0
  26. package/dist/esm/commands/app/session-logger.js +171 -0
  27. package/dist/esm/commands/app/session-logger.js.map +1 -0
  28. package/dist/esm/commands/app/tools/click.js +27 -0
  29. package/dist/esm/commands/app/tools/click.js.map +1 -0
  30. package/dist/esm/commands/app/tools/errors.js +24 -0
  31. package/dist/esm/commands/app/tools/errors.js.map +1 -0
  32. package/dist/esm/commands/app/tools/eval-js.js +36 -0
  33. package/dist/esm/commands/app/tools/eval-js.js.map +1 -0
  34. package/dist/esm/commands/app/tools/fill.js +28 -0
  35. package/dist/esm/commands/app/tools/fill.js.map +1 -0
  36. package/dist/esm/commands/app/tools/find.js +44 -0
  37. package/dist/esm/commands/app/tools/find.js.map +1 -0
  38. package/dist/esm/commands/app/tools/get-styles.js +36 -0
  39. package/dist/esm/commands/app/tools/get-styles.js.map +1 -0
  40. package/dist/esm/commands/app/tools/get-url.js +24 -0
  41. package/dist/esm/commands/app/tools/get-url.js.map +1 -0
  42. package/dist/esm/commands/app/tools/go-back.js +15 -0
  43. package/dist/esm/commands/app/tools/go-back.js.map +1 -0
  44. package/dist/esm/commands/app/tools/hover.js +24 -0
  45. package/dist/esm/commands/app/tools/hover.js.map +1 -0
  46. package/dist/esm/commands/app/tools/index.js +32 -0
  47. package/dist/esm/commands/app/tools/index.js.map +1 -0
  48. package/dist/esm/commands/app/tools/navigate.js +31 -0
  49. package/dist/esm/commands/app/tools/navigate.js.map +1 -0
  50. package/dist/esm/commands/app/tools/press-key.js +24 -0
  51. package/dist/esm/commands/app/tools/press-key.js.map +1 -0
  52. package/dist/esm/commands/app/tools/read-file.js +98 -0
  53. package/dist/esm/commands/app/tools/read-file.js.map +1 -0
  54. package/dist/esm/commands/app/tools/registry.js +34 -0
  55. package/dist/esm/commands/app/tools/registry.js.map +1 -0
  56. package/dist/esm/commands/app/tools/reload.js +15 -0
  57. package/dist/esm/commands/app/tools/reload.js.map +1 -0
  58. package/dist/esm/commands/app/tools/screenshot.js +133 -0
  59. package/dist/esm/commands/app/tools/screenshot.js.map +1 -0
  60. package/dist/esm/commands/app/tools/scroll.js +41 -0
  61. package/dist/esm/commands/app/tools/scroll.js.map +1 -0
  62. package/dist/esm/commands/app/tools/select.js +25 -0
  63. package/dist/esm/commands/app/tools/select.js.map +1 -0
  64. package/dist/esm/commands/app/tools/snapshot.js +24 -0
  65. package/dist/esm/commands/app/tools/snapshot.js.map +1 -0
  66. package/dist/esm/commands/app/tools/type-text.js +29 -0
  67. package/dist/esm/commands/app/tools/type-text.js.map +1 -0
  68. package/dist/esm/commands/app/tools/types.js +2 -0
  69. package/dist/esm/commands/app/tools/types.js.map +1 -0
  70. package/dist/esm/commands/app/tools/wait.js +39 -0
  71. package/dist/esm/commands/app/tools/wait.js.map +1 -0
  72. package/dist/esm/commands/app/tools/write-file.js +98 -0
  73. package/dist/esm/commands/app/tools/write-file.js.map +1 -0
  74. package/dist/esm/commands/app/types.js +2 -0
  75. package/dist/esm/commands/app/types.js.map +1 -0
  76. package/dist/esm/eval-resolve.js +83 -0
  77. package/dist/esm/eval-resolve.js.map +1 -0
  78. package/dist/esm/index.js +18 -0
  79. package/dist/esm/index.js.map +1 -0
  80. package/dist/esm/utils/agent-browser.js +67 -0
  81. package/dist/esm/utils/agent-browser.js.map +1 -0
  82. package/dist/esm/utils/browser-poll.js +26 -0
  83. package/dist/esm/utils/browser-poll.js.map +1 -0
  84. package/dist/esm/utils/daemon.js +83 -0
  85. package/dist/esm/utils/daemon.js.map +1 -0
  86. package/dist/esm/utils/index.js +6 -0
  87. package/dist/esm/utils/index.js.map +1 -0
  88. package/dist/esm/utils/process.js +30 -0
  89. package/dist/esm/utils/process.js.map +1 -0
  90. package/dist/esm/utils/server.js +65 -0
  91. package/dist/esm/utils/server.js.map +1 -0
  92. package/dist/esm/version.js +3 -0
  93. package/dist/esm/version.js.map +1 -0
  94. package/dist/tsconfig.tsbuildinfo +1 -0
  95. package/dist/types/commands/app/command.d.ts +7 -0
  96. package/dist/types/commands/app/eval.d.ts +9 -0
  97. package/dist/types/commands/app/format.d.ts +14 -0
  98. package/dist/types/commands/app/login.d.ts +14 -0
  99. package/dist/types/commands/app/prompts/execute-step.prompt.d.ts +13 -0
  100. package/dist/types/commands/app/prompts/judge.prompt.d.ts +15 -0
  101. package/dist/types/commands/app/prompts/plan.prompt.d.ts +13 -0
  102. package/dist/types/commands/app/prompts/step.prompt.d.ts +12 -0
  103. package/dist/types/commands/app/prompts/system.dev-server.prompt.d.ts +12 -0
  104. package/dist/types/commands/app/prompts/system.prompt.d.ts +12 -0
  105. package/dist/types/commands/app/server.d.ts +26 -0
  106. package/dist/types/commands/app/session-logger.d.ts +14 -0
  107. package/dist/types/commands/app/tools/click.d.ts +9 -0
  108. package/dist/types/commands/app/tools/errors.d.ts +9 -0
  109. package/dist/types/commands/app/tools/eval-js.d.ts +13 -0
  110. package/dist/types/commands/app/tools/fill.d.ts +9 -0
  111. package/dist/types/commands/app/tools/find.d.ts +9 -0
  112. package/dist/types/commands/app/tools/get-styles.d.ts +13 -0
  113. package/dist/types/commands/app/tools/get-url.d.ts +9 -0
  114. package/dist/types/commands/app/tools/go-back.d.ts +9 -0
  115. package/dist/types/commands/app/tools/hover.d.ts +9 -0
  116. package/dist/types/commands/app/tools/index.d.ts +9 -0
  117. package/dist/types/commands/app/tools/navigate.d.ts +9 -0
  118. package/dist/types/commands/app/tools/press-key.d.ts +9 -0
  119. package/dist/types/commands/app/tools/read-file.d.ts +13 -0
  120. package/dist/types/commands/app/tools/registry.d.ts +28 -0
  121. package/dist/types/commands/app/tools/reload.d.ts +9 -0
  122. package/dist/types/commands/app/tools/screenshot.d.ts +9 -0
  123. package/dist/types/commands/app/tools/scroll.d.ts +9 -0
  124. package/dist/types/commands/app/tools/select.d.ts +9 -0
  125. package/dist/types/commands/app/tools/snapshot.d.ts +9 -0
  126. package/dist/types/commands/app/tools/type-text.d.ts +9 -0
  127. package/dist/types/commands/app/tools/types.d.ts +12 -0
  128. package/dist/types/commands/app/tools/wait.d.ts +9 -0
  129. package/dist/types/commands/app/tools/write-file.d.ts +19 -0
  130. package/dist/types/commands/app/types.d.ts +68 -0
  131. package/dist/types/eval-resolve.d.ts +31 -0
  132. package/dist/types/index.d.ts +15 -0
  133. package/dist/types/utils/agent-browser.d.ts +26 -0
  134. package/dist/types/utils/browser-poll.d.ts +9 -0
  135. package/dist/types/utils/daemon.d.ts +16 -0
  136. package/dist/types/utils/index.d.ts +5 -0
  137. package/dist/types/utils/process.d.ts +21 -0
  138. package/dist/types/utils/server.d.ts +26 -0
  139. package/dist/types/version.d.ts +1 -0
  140. package/package.json +57 -0
  141. package/src/commands/app/command.ts +204 -0
  142. package/src/commands/app/eval.ts +114 -0
  143. package/src/commands/app/format.ts +110 -0
  144. package/src/commands/app/login.ts +92 -0
  145. package/src/commands/app/prompts/execute-step.prompt.ts +48 -0
  146. package/src/commands/app/prompts/judge.prompt.ts +121 -0
  147. package/src/commands/app/prompts/plan.prompt.ts +48 -0
  148. package/src/commands/app/prompts/step.prompt.ts +45 -0
  149. package/src/commands/app/prompts/system.dev-server.prompt.ts +22 -0
  150. package/src/commands/app/prompts/system.prompt.ts +78 -0
  151. package/src/commands/app/server.ts +105 -0
  152. package/src/commands/app/session-logger.ts +187 -0
  153. package/src/commands/app/tools/click.ts +28 -0
  154. package/src/commands/app/tools/errors.ts +26 -0
  155. package/src/commands/app/tools/eval-js.ts +40 -0
  156. package/src/commands/app/tools/fill.ts +29 -0
  157. package/src/commands/app/tools/find.ts +49 -0
  158. package/src/commands/app/tools/get-styles.ts +40 -0
  159. package/src/commands/app/tools/get-url.ts +26 -0
  160. package/src/commands/app/tools/go-back.ts +16 -0
  161. package/src/commands/app/tools/hover.ts +25 -0
  162. package/src/commands/app/tools/index.ts +57 -0
  163. package/src/commands/app/tools/navigate.ts +32 -0
  164. package/src/commands/app/tools/press-key.ts +25 -0
  165. package/src/commands/app/tools/read-file.ts +113 -0
  166. package/src/commands/app/tools/registry.ts +38 -0
  167. package/src/commands/app/tools/reload.ts +16 -0
  168. package/src/commands/app/tools/screenshot.ts +168 -0
  169. package/src/commands/app/tools/scroll.ts +45 -0
  170. package/src/commands/app/tools/select.ts +26 -0
  171. package/src/commands/app/tools/snapshot.ts +27 -0
  172. package/src/commands/app/tools/type-text.ts +30 -0
  173. package/src/commands/app/tools/types.ts +15 -0
  174. package/src/commands/app/tools/wait.ts +43 -0
  175. package/src/commands/app/tools/write-file.ts +104 -0
  176. package/src/commands/app/types.ts +76 -0
  177. package/src/eval-resolve.ts +113 -0
  178. package/src/index.ts +20 -0
  179. package/src/utils/agent-browser.ts +66 -0
  180. package/src/utils/browser-poll.ts +27 -0
  181. package/src/utils/daemon.ts +84 -0
  182. package/src/utils/index.ts +5 -0
  183. package/src/utils/process.ts +33 -0
  184. package/src/utils/server.ts +66 -0
  185. package/src/version.ts +2 -0
  186. package/tsconfig.json +13 -0
@@ -0,0 +1,30 @@
1
+ import type { AgentBrowserToolContext, DefineTool } from './types.js';
2
+
3
+ /**
4
+ * Creates the text typing tool.
5
+ *
6
+ * @param context - Shared browser tool execution context
7
+ * @param defineTool - Copilot SDK helper used to declare tools
8
+ * @returns Copilot tool definition for typing text into the focused element or a target
9
+ */
10
+ export function createTypeTextTool(context: AgentBrowserToolContext, defineTool: DefineTool) {
11
+ return defineTool('browser_type', {
12
+ description: 'Type text character by character (useful for inputs that respond to keystrokes).',
13
+ parameters: {
14
+ type: 'object' as const,
15
+ properties: {
16
+ target: {
17
+ type: 'string',
18
+ description: 'Element ref or CSS selector (omit to type into focused element)',
19
+ },
20
+ text: { type: 'string', description: 'Text to type' },
21
+ },
22
+ required: ['text'],
23
+ },
24
+ handler: async (args) => {
25
+ const { target, text } = args as { target?: string; text: string };
26
+ const command = target ? ['type', target, text] : ['keyboard', 'type', text];
27
+ return context.invoke(command);
28
+ },
29
+ });
30
+ }
@@ -0,0 +1,15 @@
1
+ import type { SessionConfig, defineTool } from '@github/copilot-sdk';
2
+
3
+ /** Shared runtime context passed to every agent-browser tool factory. */
4
+ export interface AgentBrowserToolContext {
5
+ /** Directory where evidence artifacts for the current eval run are stored. */
6
+ outDir: string;
7
+ /** Executes an `agent-browser` command and returns its stdout. */
8
+ invoke: (args: string[], timeoutMs?: number) => string;
9
+ }
10
+
11
+ /** Type-only alias for the Copilot SDK `defineTool` helper. */
12
+ export type DefineTool = typeof defineTool;
13
+
14
+ /** Copilot SDK tool list type used by the registry. */
15
+ export type AgentBrowserToolList = NonNullable<SessionConfig['tools']>;
@@ -0,0 +1,43 @@
1
+ import type { AgentBrowserToolContext, DefineTool } from './types.js';
2
+
3
+ /**
4
+ * Creates the wait tool.
5
+ *
6
+ * @param context - Shared browser tool execution context
7
+ * @param defineTool - Copilot SDK helper used to declare tools
8
+ * @returns Copilot tool definition for waiting on load state, text, elements, or time
9
+ */
10
+ export function createWaitTool(context: AgentBrowserToolContext, defineTool: DefineTool) {
11
+ return defineTool('browser_wait', {
12
+ description:
13
+ 'Wait for a condition: page load (networkidle), text to appear, an element, or a timeout in ms.',
14
+ parameters: {
15
+ type: 'object' as const,
16
+ properties: {
17
+ text: { type: 'string', description: 'Wait for this text to appear on the page' },
18
+ selector: { type: 'string', description: 'Wait for this element ref or CSS selector' },
19
+ load: {
20
+ type: 'string',
21
+ description: 'Wait for page load state: "networkidle", "domcontentloaded", or "load"',
22
+ },
23
+ timeout: { type: 'number', description: 'Wait for this many milliseconds' },
24
+ },
25
+ },
26
+ handler: async (args) => {
27
+ const { text, selector, load, timeout } = args as {
28
+ text?: string;
29
+ selector?: string;
30
+ load?: string;
31
+ timeout?: number;
32
+ };
33
+ if (text) return context.invoke(['wait', '--text', text], 60_000);
34
+ if (load) return context.invoke(['wait', '--load', load], 60_000);
35
+ if (selector) return context.invoke(['wait', selector], 60_000);
36
+ if (timeout) {
37
+ await new Promise((resolve) => setTimeout(resolve, timeout));
38
+ return `Waited ${timeout}ms`;
39
+ }
40
+ return context.invoke(['wait', '--load', 'networkidle'], 60_000);
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,104 @@
1
+ import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join } from 'node:path';
3
+
4
+ import type { DefineTool } from './types.js';
5
+
6
+ /**
7
+ * Resolves a path within a base directory, rejecting escapes.
8
+ *
9
+ * Accepts both relative paths and absolute paths that fall within
10
+ * the base directory. Strips the base prefix from absolute paths
11
+ * before resolving to handle models that echo full paths from prompts.
12
+ *
13
+ * @param baseDir - The allowed root directory
14
+ * @param input - The user-provided path (relative or absolute within baseDir)
15
+ * @returns The resolved absolute path
16
+ * @throws {Error} When the path escapes the base directory
17
+ */
18
+ function resolveSafe(baseDir: string, input: string): string {
19
+ // If the model echoed the full absolute path from the prompt, strip the base prefix.
20
+ const relative = isAbsolute(input)
21
+ ? input.startsWith(baseDir + '/') || input.startsWith(baseDir + '\\')
22
+ ? input.slice(baseDir.length + 1)
23
+ : (() => {
24
+ throw new Error(`Absolute path outside output directory: ${input}`);
25
+ })()
26
+ : input;
27
+ const resolved = join(baseDir, relative);
28
+ if (!resolved.startsWith(baseDir)) {
29
+ throw new Error(`Path escapes the output directory: ${input}`);
30
+ }
31
+ return resolved;
32
+ }
33
+
34
+ /**
35
+ * Creates the write-file tool so the agent can save artifacts
36
+ * (plan.json, verdict.json, etc.) to the run output directory.
37
+ *
38
+ * @param baseDir - The run output directory that scopes all writes
39
+ * @param defineTool - Copilot SDK helper used to declare tools
40
+ * @returns Copilot tool definition for writing a file
41
+ */
42
+ export function createWriteFileTool(baseDir: string, defineTool: DefineTool) {
43
+ return defineTool('write_file', {
44
+ description:
45
+ 'Write content to a file in the output directory. Creates parent directories as needed.',
46
+ parameters: {
47
+ type: 'object' as const,
48
+ properties: {
49
+ path: {
50
+ type: 'string',
51
+ description: 'File path relative to the output directory (e.g. "plan.json")',
52
+ },
53
+ content: {
54
+ type: 'string',
55
+ description: 'The full file content to write',
56
+ },
57
+ },
58
+ required: ['path', 'content'],
59
+ },
60
+ handler: async (args) => {
61
+ const { path, content } = args as { path: string; content: string };
62
+ const resolved = resolveSafe(baseDir, path);
63
+ mkdirSync(dirname(resolved), { recursive: true });
64
+ writeFileSync(resolved, content);
65
+ return `Wrote ${content.length} bytes to ${path}`;
66
+ },
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Creates the append-file tool so the agent can append lines
72
+ * to artifacts like executions.jsonl.
73
+ *
74
+ * @param baseDir - The run output directory that scopes all writes
75
+ * @param defineTool - Copilot SDK helper used to declare tools
76
+ * @returns Copilot tool definition for appending to a file
77
+ */
78
+ export function createAppendFileTool(baseDir: string, defineTool: DefineTool) {
79
+ return defineTool('append_file', {
80
+ description:
81
+ 'Append content to a file in the output directory. Creates the file if it does not exist.',
82
+ parameters: {
83
+ type: 'object' as const,
84
+ properties: {
85
+ path: {
86
+ type: 'string',
87
+ description: 'File path relative to the output directory (e.g. "executions.jsonl")',
88
+ },
89
+ content: {
90
+ type: 'string',
91
+ description: 'Content to append (a trailing newline is added automatically)',
92
+ },
93
+ },
94
+ required: ['path', 'content'],
95
+ },
96
+ handler: async (args) => {
97
+ const { path, content } = args as { path: string; content: string };
98
+ const resolved = resolveSafe(baseDir, path);
99
+ mkdirSync(dirname(resolved), { recursive: true });
100
+ appendFileSync(resolved, content.endsWith('\n') ? content : content + '\n');
101
+ return `Appended to ${path}`;
102
+ },
103
+ });
104
+ }
@@ -0,0 +1,76 @@
1
+ import type { Observable } from 'rxjs';
2
+
3
+ export interface RuntimeExecutionContext {
4
+ outDir: string;
5
+ url: string;
6
+ }
7
+
8
+ /** CLI option set for the `copilot app eval` command. */
9
+ export interface CopilotEvalOptions {
10
+ /** Port for the local app dev server. */
11
+ port: string;
12
+ /** Host address for the local app dev server. */
13
+ host: string;
14
+ /** Skip server start and use an already-running URL. */
15
+ url?: string;
16
+ /** Show detailed output from the dev server and agent-browser. */
17
+ verbose: boolean;
18
+ /** LLM model override (e.g. "claude-sonnet-4"). */
19
+ model?: string;
20
+ /** Reasoning effort: low, medium, high, xhigh. */
21
+ reasoning?: 'low' | 'medium' | 'high' | 'xhigh';
22
+ /** Output directory override for run artifacts. */
23
+ output?: string;
24
+ /** Open a headed browser for interactive MSAL login. */
25
+ login: boolean;
26
+ /** Specific eval to run (name or file path). */
27
+ eval?: string;
28
+ /** Observable that emits on SIGINT for graceful shutdown. */
29
+ shutdown$?: Observable<string>;
30
+ }
31
+
32
+ // ── Eval plan types ─────────────────────────────────────────────────────────
33
+
34
+ /** A logical test step that groups related criteria under one scenario. */
35
+ export interface PlanStep {
36
+ /** Short scenario description (e.g. "Open the landing page"). */
37
+ scenario: string;
38
+ /** Acceptance criteria to verify (e.g. ["Header shows Hello Fusion", ...]). */
39
+ criteria: string[];
40
+ /** What constitutes a pass for each criterion (same order as criteria). */
41
+ pass: string[];
42
+ /** What constitutes a fail for each criterion (same order as criteria). */
43
+ fail: string[];
44
+ }
45
+
46
+ /** Structured plan produced by Phase 1. */
47
+ export interface Plan {
48
+ /** Brief summary of the feature under test. */
49
+ summary: string;
50
+ /** Ordered acceptance criteria to execute. */
51
+ steps: PlanStep[];
52
+ }
53
+
54
+ /** Result of executing a single {@link PlanStep}. */
55
+ export interface StepResult {
56
+ /** The criterion that was tested. */
57
+ criterion: string;
58
+ /** Whether the criterion passed. */
59
+ ok: boolean;
60
+ /** Agent's observation or explanation. */
61
+ note: string;
62
+ /** Evidence artifact filenames collected during this step. */
63
+ evidence: string[];
64
+ }
65
+
66
+ /** Structured verdict produced by the Copilot agent at the end of an eval session. */
67
+ export interface Verdict {
68
+ /** Whether all acceptance criteria passed. */
69
+ pass: boolean;
70
+ /** Overall summary explaining the pass/fail outcome. */
71
+ reasoning: string;
72
+ /** Per-criterion results with evidence references. */
73
+ steps: StepResult[];
74
+ /** Optional UX observations and improvement suggestions based on what was seen. */
75
+ ux?: string[];
76
+ }
@@ -0,0 +1,113 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
3
+
4
+ /**
5
+ * Normalized positional input for the `copilot app eval` command.
6
+ *
7
+ * When the user passes an application directory, `evalFile` is undefined.
8
+ * When the user passes a direct `eval/*.md` file path, `appPath` is derived
9
+ * from the file location and `evalFile` points to the selected markdown file.
10
+ */
11
+ export interface EvalCommandInput {
12
+ /** Absolute path to the Fusion application directory. */
13
+ appPath: string;
14
+ /** Optional absolute path to the directly selected eval markdown file. */
15
+ evalFile?: string;
16
+ }
17
+
18
+ /**
19
+ * Prints a CLI resolution error and exits the current process.
20
+ *
21
+ * @param message - Primary error message.
22
+ * @param detail - Optional follow-up detail shown on the next line.
23
+ * @returns This function never returns because the process exits.
24
+ */
25
+ function exitWithResolutionError(message: string, detail?: string): never {
26
+ console.error(`❌ ${message}`);
27
+ if (detail) {
28
+ console.error(` ${detail}`);
29
+ }
30
+ process.exit(1);
31
+ }
32
+
33
+ /**
34
+ * Resolves the positional `copilot app eval` argument.
35
+ *
36
+ * Supports both application directory paths and direct paths to markdown files
37
+ * inside an application's `eval/` directory.
38
+ *
39
+ * @param inputPath - Positional CLI argument passed to the eval command.
40
+ * @returns Normalized application path and optional direct eval file path.
41
+ */
42
+ export function resolveEvalCommandInput(inputPath: string): EvalCommandInput {
43
+ const absInput = resolve(inputPath);
44
+
45
+ if (extname(absInput) !== '.md') {
46
+ return { appPath: absInput };
47
+ }
48
+
49
+ if (!existsSync(absInput)) {
50
+ exitWithResolutionError(`Eval file not found: ${absInput}`);
51
+ }
52
+
53
+ if (!statSync(absInput).isFile()) {
54
+ exitWithResolutionError(`Eval path is not a file: ${absInput}`);
55
+ }
56
+
57
+ const evalDir = dirname(absInput);
58
+ if (basename(evalDir) !== 'eval') {
59
+ exitWithResolutionError(
60
+ `Direct eval file path must point to a markdown file inside an eval directory: ${absInput}`,
61
+ );
62
+ }
63
+
64
+ return {
65
+ appPath: dirname(evalDir),
66
+ evalFile: absInput,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Resolves eval markdown files for a Fusion application.
72
+ *
73
+ * @param appPath - Path to the Fusion application directory
74
+ * @param evalOpt - Value of the `--eval` CLI option (name, path, or `undefined`)
75
+ * @returns Array of absolute paths to eval markdown files
76
+ */
77
+ export function resolveEvalFiles(appPath: string, evalOpt?: string): string[] {
78
+ const absApp = resolve(appPath);
79
+
80
+ if (!evalOpt) {
81
+ const evalDir = join(absApp, 'eval');
82
+ if (!existsSync(evalDir)) {
83
+ exitWithResolutionError(
84
+ `No eval directory found at ${evalDir}`,
85
+ 'Create eval/*.md files or use --eval <file>',
86
+ );
87
+ }
88
+ const files = readdirSync(evalDir)
89
+ .filter((f) => f.endsWith('.md'))
90
+ .sort()
91
+ .map((f) => join(evalDir, f));
92
+
93
+ if (files.length === 0) {
94
+ exitWithResolutionError(`No .md files found in ${evalDir}`);
95
+ }
96
+ return files;
97
+ }
98
+
99
+ if (isAbsolute(evalOpt) || evalOpt.includes('/') || evalOpt.includes('\\')) {
100
+ const absPath = isAbsolute(evalOpt) ? evalOpt : resolve(evalOpt);
101
+ if (!existsSync(absPath)) {
102
+ exitWithResolutionError(`Eval file not found: ${absPath}`);
103
+ }
104
+ return [absPath];
105
+ }
106
+
107
+ const evalFileName = extname(evalOpt) === '.md' ? evalOpt : `${evalOpt}.md`;
108
+ const evalFile = join(absApp, 'eval', evalFileName);
109
+ if (!existsSync(evalFile)) {
110
+ exitWithResolutionError(`Eval file not found: ${evalFile}`);
111
+ }
112
+ return [evalFile];
113
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { Command } from 'commander';
2
+ import { copilotCommand } from './commands/app/command.js';
3
+
4
+ /**
5
+ * Registers the `copilot` CLI plugin with the Fusion Framework CLI.
6
+ *
7
+ * Attaches the top-level `copilot` command group, providing Copilot SDK
8
+ * powered evaluation tools with agent-browser.
9
+ *
10
+ * Usage:
11
+ * ffc copilot app eval ./cookbooks/app-react
12
+ * ffc copilot app eval . --eval smoke --model claude-sonnet-4
13
+ *
14
+ * @param program - The root Commander program instance
15
+ */
16
+ export function registerCopilotPlugin(program: Command): void {
17
+ program.addCommand(copilotCommand);
18
+ }
19
+
20
+ export default registerCopilotPlugin;
@@ -0,0 +1,66 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, unlinkSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ /** Persistent Chrome profile directory used across agent-browser sessions. */
7
+ export const PROFILE_DIR = `${homedir()}/.fusion-smoke-profile`;
8
+
9
+ /**
10
+ * Runs an agent-browser command with the persistent Chrome profile.
11
+ *
12
+ * Calls the `agent-browser` binary resolved from `PATH` (homebrew or npm global).
13
+ * Every invocation automatically injects `--profile <PROFILE_DIR>` so browser
14
+ * state (cookies, session storage, MSAL tokens) persists across calls.
15
+ *
16
+ * @param args - Subcommand and arguments passed directly to `agent-browser`
17
+ * @param timeoutMs - Maximum execution time in milliseconds (default: 30 000)
18
+ * @returns Trimmed stdout from the command
19
+ * @throws {Error} When `agent-browser` exits with a non-zero code or times out
20
+ */
21
+ export function ab(args: string[], timeoutMs = 30_000): string {
22
+ // Remove stale SingletonLock left by a previous Chrome crash to prevent
23
+ // "Failed to create SingletonLock: File exists" errors.
24
+ const lockPath = join(PROFILE_DIR, 'SingletonLock');
25
+ if (existsSync(lockPath)) {
26
+ try {
27
+ unlinkSync(lockPath);
28
+ } catch {
29
+ // Best-effort — if we can't remove it Chrome will fail with a clear message anyway.
30
+ }
31
+ }
32
+ const fullArgs = ['--profile', PROFILE_DIR, ...args];
33
+ return execFileSync('agent-browser', fullArgs, {
34
+ encoding: 'utf-8',
35
+ timeout: timeoutMs,
36
+ stdio: ['ignore', 'pipe', 'pipe'],
37
+ cwd: process.cwd(),
38
+ }).trim();
39
+ }
40
+
41
+ /**
42
+ * Extracts the user-facing error message from an agent-browser failure.
43
+ *
44
+ * Strips ANSI escape codes from stderr and returns only the diagnostic
45
+ * lines prefixed with `✗` or `⚠`. Falls back to the first 200 characters
46
+ * of the error message when no diagnostic lines are found.
47
+ *
48
+ * @param err - The caught error (typically from {@link ab})
49
+ * @returns A single-line summary suitable for console output
50
+ */
51
+ export function abErrorMessage(err: unknown): string {
52
+ if (!(err instanceof Error)) return String(err);
53
+ const e = err as Error & { stderr?: string };
54
+ if (e.stderr) {
55
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences
56
+ const ansiPattern = /\x1B\[[0-9;]*m/g;
57
+ const lines = e.stderr
58
+ .replace(ansiPattern, '')
59
+ .split('\n')
60
+ .map((l) => l.trim())
61
+ .filter((l) => l.startsWith('✗') || l.startsWith('⚠'));
62
+ if (lines.length) return lines.join(' | ');
63
+ }
64
+ const match = e.message.match(/[✗⚠].+/);
65
+ return match ? match[0] : e.message.slice(0, 200);
66
+ }
@@ -0,0 +1,27 @@
1
+ import { ab } from './agent-browser.js';
2
+ import { sleep } from './process.js';
3
+
4
+ /**
5
+ * Polls the browser's console log output until a predicate returns `true`
6
+ * or the timeout expires.
7
+ *
8
+ * @param predicate - Callback receiving the raw console log text; return `true` to stop
9
+ * @param timeoutMs - Maximum polling duration in milliseconds
10
+ * @returns `true` if the predicate matched before timeout, `false` otherwise
11
+ */
12
+ export async function pollConsole(
13
+ predicate: (logs: string) => boolean,
14
+ timeoutMs: number,
15
+ ): Promise<boolean> {
16
+ const deadline = Date.now() + timeoutMs;
17
+ while (Date.now() < deadline) {
18
+ try {
19
+ const logs = ab(['console']);
20
+ if (predicate(logs)) return true;
21
+ } catch {
22
+ // Browser not ready — retry
23
+ }
24
+ await sleep(2000);
25
+ }
26
+ return false;
27
+ }
@@ -0,0 +1,84 @@
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+
6
+ import { ab, PROFILE_DIR } from './agent-browser.js';
7
+
8
+ /** PID file written by the agent-browser daemon to track its own process. */
9
+ const DAEMON_PID_FILE = join(homedir(), '.agent-browser', 'default.pid');
10
+
11
+ /**
12
+ * Kills any running agent-browser daemon and its Chrome children,
13
+ * then removes stale state files so the next run starts clean.
14
+ *
15
+ * Cleanup sequence:
16
+ * 1. Graceful `agent-browser close` (5 s timeout)
17
+ * 2. Force-kill the daemon PID read from {@link DAEMON_PID_FILE}
18
+ * 3. `pkill` leftover headless Chrome processes
19
+ * 4. Delete Session Storage to clear stuck MSAL `interaction.status` flags
20
+ */
21
+ export function resetDaemon(): void {
22
+ // 1. Try graceful close first
23
+ try {
24
+ execFileSync('agent-browser', ['close'], {
25
+ encoding: 'utf-8',
26
+ timeout: 5_000,
27
+ stdio: 'ignore',
28
+ });
29
+ console.log('🔄 Closed agent-browser daemon');
30
+ } catch {
31
+ // Daemon not responding — fall through to force-kill
32
+ }
33
+
34
+ // 2. Kill the daemon process tracked by agent-browser's own pid file
35
+ if (existsSync(DAEMON_PID_FILE)) {
36
+ try {
37
+ const pid = parseInt(readFileSync(DAEMON_PID_FILE, 'utf-8').trim(), 10);
38
+ if (pid > 0) process.kill(pid);
39
+ console.log('🔄 Killed agent-browser daemon (pid %d)', pid);
40
+ } catch {
41
+ // Process already dead — acceptable
42
+ }
43
+ rmSync(DAEMON_PID_FILE, { force: true });
44
+ }
45
+
46
+ // 3. Force-kill any leftover headless Chrome processes
47
+ try {
48
+ execSync("pkill -f 'Google Chrome for Testing.*--headless'", {
49
+ stdio: 'ignore',
50
+ timeout: 3_000,
51
+ });
52
+ } catch {
53
+ // No matching processes — acceptable
54
+ }
55
+
56
+ // 4. Remove stale SingletonLock left by crashed Chrome
57
+ const lockPath = join(PROFILE_DIR, 'SingletonLock');
58
+ if (existsSync(lockPath)) {
59
+ try {
60
+ rmSync(lockPath, { force: true });
61
+ } catch {
62
+ // Best-effort
63
+ }
64
+ }
65
+
66
+ // 5. Clear Session Storage to remove stuck MSAL interaction.status flags
67
+ const sessionStorage = join(PROFILE_DIR, 'Default', 'Session Storage');
68
+ if (existsSync(sessionStorage)) {
69
+ rmSync(sessionStorage, { recursive: true, force: true });
70
+ console.log('🔄 Cleared Session Storage (MSAL interaction flags)');
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Clears the MSAL `interaction.status` flag from `sessionStorage` in the
76
+ * running browser via `agent-browser eval`.
77
+ */
78
+ export function clearMsalInteraction(): void {
79
+ try {
80
+ ab(['eval', 'sessionStorage.removeItem("msal.interaction.status")'], 5_000);
81
+ } catch {
82
+ // Browser may not be ready yet — non-fatal
83
+ }
84
+ }
@@ -0,0 +1,5 @@
1
+ export { ab, abErrorMessage, PROFILE_DIR } from './agent-browser.js';
2
+ export { resetDaemon, clearMsalInteraction } from './daemon.js';
3
+ export { cleanup, sleep, stripAnsi } from './process.js';
4
+ export { createRunDir, resolveAppKey, waitForServer } from './server.js';
5
+ export { pollConsole } from './browser-poll.js';
@@ -0,0 +1,33 @@
1
+ import type { ChildProcess } from 'node:child_process';
2
+
3
+ /**
4
+ * Sends `SIGTERM` to a child process if it is still running.
5
+ *
6
+ * @param proc - The child process to terminate (no-op when `undefined` or already killed)
7
+ */
8
+ export function cleanup(proc?: ChildProcess): void {
9
+ if (proc && !proc.killed) {
10
+ proc.kill('SIGTERM');
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Returns a promise that resolves after the given delay.
16
+ *
17
+ * @param ms - Delay in milliseconds
18
+ * @returns A promise that resolves after `ms` milliseconds
19
+ */
20
+ export function sleep(ms: number): Promise<void> {
21
+ return new Promise((r) => setTimeout(r, ms));
22
+ }
23
+
24
+ /**
25
+ * Strips ANSI escape codes (color / formatting) from a string.
26
+ *
27
+ * @param s - The string potentially containing ANSI sequences
28
+ * @returns The input string with all ANSI escape sequences removed
29
+ */
30
+ export function stripAnsi(s: string): string {
31
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences
32
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
33
+ }