@agentrhq/webcmd 0.2.5 → 0.3.1

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 (200) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +217 -13
  3. package/clis/_shared/site-auth.js +3 -3
  4. package/clis/_shared/site-auth.test.js +4 -4
  5. package/clis/chatgpt/ask.js +1 -1
  6. package/clis/chatgpt/ask.test.js +11 -0
  7. package/clis/chatgpt/commands.test.js +102 -1
  8. package/clis/chatgpt/deep-research-result.js +45 -3
  9. package/clis/chatgpt/image.js +22 -2
  10. package/clis/chatgpt/project-file-add.js +24 -1
  11. package/clis/chatgpt/utils.js +258 -52
  12. package/clis/chatgpt/utils.test.js +259 -1
  13. package/clis/claude/ask.js +21 -1
  14. package/clis/facebook/feed.js +110 -24
  15. package/clis/facebook/feed.test.js +62 -0
  16. package/clis/facebook/search.js +185 -38
  17. package/clis/facebook/search.test.js +95 -50
  18. package/clis/instagram/explore.js +30 -12
  19. package/clis/instagram/explore.test.js +77 -0
  20. package/clis/instagram/post.js +14 -1
  21. package/clis/instagram/reel.js +13 -1
  22. package/clis/linkedin/company.js +153 -0
  23. package/clis/linkedin/company.test.js +111 -0
  24. package/clis/linkedin/connections.js +135 -0
  25. package/clis/linkedin/connections.test.js +141 -0
  26. package/clis/mercury/reimbursement-draft.js +12 -1
  27. package/clis/slock/whoami.test.js +2 -2
  28. package/clis/twitter/article-evaluate.test.js +27 -0
  29. package/clis/twitter/article.js +73 -16
  30. package/clis/twitter/article.test.js +209 -0
  31. package/clis/twitter/download.js +6 -1
  32. package/clis/twitter/post.js +14 -1
  33. package/clis/twitter/profile.js +1 -1
  34. package/clis/twitter/profile.test.js +8 -0
  35. package/clis/twitter/quote.js +11 -1
  36. package/clis/twitter/reply.js +11 -1
  37. package/dist/src/browser/base-page.d.ts +26 -0
  38. package/dist/src/browser/base-page.js +23 -8
  39. package/dist/src/browser/base-page.test.js +45 -12
  40. package/dist/src/browser/command-catalog.d.ts +6 -0
  41. package/dist/src/browser/command-catalog.js +259 -0
  42. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  43. package/dist/src/browser/command-catalog.test.js +79 -0
  44. package/dist/src/browser/daemon-client.d.ts +2 -1
  45. package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  47. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  48. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  49. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  50. package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
  51. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +10 -2
  52. package/dist/src/browser/runtime/local-cloak/session-manager.js +25 -9
  53. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  54. package/dist/src/browser/target-resolver.d.ts +1 -0
  55. package/dist/src/browser/target-resolver.js +99 -4
  56. package/dist/src/browser/target-resolver.test.js +141 -1
  57. package/dist/src/build-manifest.d.ts +6 -0
  58. package/dist/src/build-manifest.js +18 -1
  59. package/dist/src/build-manifest.test.js +57 -1
  60. package/dist/src/builtin-command-surface.d.ts +9 -0
  61. package/dist/src/builtin-command-surface.js +16 -0
  62. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  63. package/dist/src/check-hosted-contract.test.js +56 -0
  64. package/dist/src/cli.js +63 -110
  65. package/dist/src/cli.test.js +82 -6
  66. package/dist/src/command-presentation.d.ts +95 -0
  67. package/dist/src/command-presentation.js +486 -0
  68. package/dist/src/command-presentation.test.d.ts +1 -0
  69. package/dist/src/command-presentation.test.js +97 -0
  70. package/dist/src/command-surface.d.ts +43 -0
  71. package/dist/src/command-surface.js +205 -0
  72. package/dist/src/command-surface.test.d.ts +1 -0
  73. package/dist/src/command-surface.test.js +406 -0
  74. package/dist/src/commanderAdapter.d.ts +5 -1
  75. package/dist/src/commanderAdapter.js +12 -55
  76. package/dist/src/commanderAdapter.test.js +15 -3
  77. package/dist/src/commands/auth.js +3 -2
  78. package/dist/src/commands/auth.test.js +6 -6
  79. package/dist/src/community-plugin-sync.d.ts +11 -0
  80. package/dist/src/community-plugin-sync.js +138 -0
  81. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  82. package/dist/src/community-plugin-sync.test.js +123 -0
  83. package/dist/src/completion-fast.d.ts +4 -4
  84. package/dist/src/completion-fast.js +15 -34
  85. package/dist/src/completion-shared.d.ts +4 -0
  86. package/dist/src/completion-shared.js +38 -0
  87. package/dist/src/completion.js +3 -27
  88. package/dist/src/completion.test.js +31 -2
  89. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  90. package/dist/src/docs-sync-review-cli.test.js +322 -0
  91. package/dist/src/docs-sync-review.d.ts +117 -0
  92. package/dist/src/docs-sync-review.js +475 -0
  93. package/dist/src/docs-sync-review.test.d.ts +1 -0
  94. package/dist/src/docs-sync-review.test.js +324 -0
  95. package/dist/src/errors.d.ts +12 -3
  96. package/dist/src/errors.js +11 -7
  97. package/dist/src/errors.test.js +14 -1
  98. package/dist/src/execution.d.ts +1 -2
  99. package/dist/src/execution.js +4 -45
  100. package/dist/src/generate-release-notes-cli.test.js +11 -1
  101. package/dist/src/help.d.ts +4 -0
  102. package/dist/src/help.js +50 -255
  103. package/dist/src/help.test.js +27 -1
  104. package/dist/src/hosted/args.d.ts +4 -0
  105. package/dist/src/hosted/args.js +4 -0
  106. package/dist/src/hosted/args.test.d.ts +1 -0
  107. package/dist/src/hosted/args.test.js +49 -0
  108. package/dist/src/hosted/availability.d.ts +13 -0
  109. package/dist/src/hosted/availability.js +16 -0
  110. package/dist/src/hosted/availability.test.d.ts +1 -0
  111. package/dist/src/hosted/availability.test.js +180 -0
  112. package/dist/src/hosted/browser-args.d.ts +18 -0
  113. package/dist/src/hosted/browser-args.js +152 -0
  114. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  115. package/dist/src/hosted/browser-args.test.js +182 -0
  116. package/dist/src/hosted/client.d.ts +59 -0
  117. package/dist/src/hosted/client.js +510 -0
  118. package/dist/src/hosted/client.test.d.ts +1 -0
  119. package/dist/src/hosted/client.test.js +738 -0
  120. package/dist/src/hosted/config.d.ts +55 -0
  121. package/dist/src/hosted/config.js +112 -0
  122. package/dist/src/hosted/config.test.d.ts +1 -0
  123. package/dist/src/hosted/config.test.js +81 -0
  124. package/dist/src/hosted/contract.d.ts +94 -0
  125. package/dist/src/hosted/contract.js +208 -0
  126. package/dist/src/hosted/contract.test.d.ts +1 -0
  127. package/dist/src/hosted/contract.test.js +361 -0
  128. package/dist/src/hosted/credentials.d.ts +38 -0
  129. package/dist/src/hosted/credentials.js +248 -0
  130. package/dist/src/hosted/credentials.test.d.ts +1 -0
  131. package/dist/src/hosted/credentials.test.js +93 -0
  132. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  133. package/dist/src/hosted/file-contract.test.js +174 -0
  134. package/dist/src/hosted/files.d.ts +29 -0
  135. package/dist/src/hosted/files.js +296 -0
  136. package/dist/src/hosted/files.test.d.ts +1 -0
  137. package/dist/src/hosted/files.test.js +231 -0
  138. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  139. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  140. package/dist/src/hosted/manifest.d.ts +14 -0
  141. package/dist/src/hosted/manifest.js +47 -0
  142. package/dist/src/hosted/manifest.test.d.ts +1 -0
  143. package/dist/src/hosted/manifest.test.js +164 -0
  144. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  145. package/dist/src/hosted/output-parity.test.js +108 -0
  146. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  147. package/dist/src/hosted/root-command-surface.test.js +646 -0
  148. package/dist/src/hosted/runner.d.ts +19 -0
  149. package/dist/src/hosted/runner.js +705 -0
  150. package/dist/src/hosted/runner.test.d.ts +1 -0
  151. package/dist/src/hosted/runner.test.js +1607 -0
  152. package/dist/src/hosted/setup.d.ts +10 -0
  153. package/dist/src/hosted/setup.js +79 -0
  154. package/dist/src/hosted/setup.test.d.ts +1 -0
  155. package/dist/src/hosted/setup.test.js +195 -0
  156. package/dist/src/hosted/types.d.ts +175 -0
  157. package/dist/src/hosted/types.js +1 -0
  158. package/dist/src/main.js +123 -97
  159. package/dist/src/manifest-types.d.ts +2 -0
  160. package/dist/src/output.d.ts +17 -2
  161. package/dist/src/output.js +88 -81
  162. package/dist/src/output.test.js +141 -51
  163. package/dist/src/pipeline/executor.test.js +1 -0
  164. package/dist/src/pipeline/steps/download.test.js +1 -0
  165. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  166. package/dist/src/plugin-create-cli.test.js +37 -0
  167. package/dist/src/plugin-manifest.d.ts +12 -0
  168. package/dist/src/plugin-manifest.js +16 -0
  169. package/dist/src/plugin-manifest.test.js +19 -1
  170. package/dist/src/plugin-scaffold.d.ts +4 -1
  171. package/dist/src/plugin-scaffold.js +4 -1
  172. package/dist/src/plugin-scaffold.test.js +23 -8
  173. package/dist/src/plugin.js +4 -1
  174. package/dist/src/plugin.test.js +13 -0
  175. package/dist/src/registry.d.ts +2 -0
  176. package/dist/src/release-notes.d.ts +6 -1
  177. package/dist/src/release-notes.js +192 -4
  178. package/dist/src/release-notes.test.js +143 -4
  179. package/dist/src/root-command-surface.d.ts +31 -0
  180. package/dist/src/root-command-surface.js +106 -0
  181. package/dist/src/serialization.d.ts +1 -16
  182. package/dist/src/serialization.js +5 -55
  183. package/dist/src/stream-write.d.ts +12 -0
  184. package/dist/src/stream-write.js +91 -0
  185. package/dist/src/stream-write.test.d.ts +1 -0
  186. package/dist/src/stream-write.test.js +186 -0
  187. package/dist/src/types.d.ts +5 -0
  188. package/dist/src/utils.d.ts +1 -1
  189. package/dist/src/utils.js +2 -8
  190. package/dist/src/utils.test.js +50 -0
  191. package/hosted-contract.json +36748 -0
  192. package/package.json +6 -1
  193. package/scripts/check-hosted-contract.mjs +108 -0
  194. package/scripts/docs-sync-review.ts +332 -0
  195. package/scripts/generate-release-notes.ts +1 -1
  196. package/scripts/sync-community-plugins.ts +14 -0
  197. package/skills/smart-search/SKILL.md +14 -3
  198. package/skills/webcmd-autofix/SKILL.md +26 -37
  199. package/skills/webcmd-usage/SKILL.md +21 -7
  200. package/clis/antigravity/SKILL.md +0 -38
@@ -1,53 +1,143 @@
1
- import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
- import { render } from './output.js';
3
- describe('output TTY detection', () => {
4
- const originalIsTTY = process.stdout.isTTY;
5
- let logSpy;
6
- beforeEach(() => {
7
- logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
8
- });
9
- afterEach(() => {
10
- Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true });
11
- logSpy.mockRestore();
12
- });
13
- it('outputs YAML in non-TTY when format is default table', () => {
14
- Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
15
- // commanderAdapter always passes fmt:'table' as default — this must still trigger downgrade
16
- render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] });
17
- const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
18
- expect(out).toContain('name: alice');
19
- expect(out).toContain('score: 10');
20
- });
21
- it('outputs table in TTY when format is default table', () => {
22
- Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
23
- render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] });
24
- const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
25
- expect(out).toContain('alice');
26
- });
27
- it('respects explicit -f json even in non-TTY', () => {
28
- Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
29
- render([{ name: 'alice' }], { fmt: 'json' });
30
- const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
31
- expect(JSON.parse(out)).toEqual([{ name: 'alice' }]);
32
- });
33
- it('shows elapsed time when elapsed is 0', () => {
34
- Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
35
- render([{ name: 'alice' }], { fmt: 'table', columns: ['name'], elapsed: 0 });
36
- const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
37
- expect(out).toContain('0.0s');
38
- });
39
- it('explicit -f table overrides non-TTY auto-downgrade', () => {
40
- Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
41
- render([{ name: 'alice' }], { fmt: 'table', fmtExplicit: true, columns: ['name'] });
42
- const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
43
- // Should be table output, not YAML
44
- expect(out).not.toContain('name: alice');
45
- expect(out).toContain('alice');
46
- });
47
- it('prints single markdown payloads without wrapping them in a table', () => {
48
- render([{ markdown: '# Title\n\nBody' }], { fmt: 'md' });
49
- const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
50
- expect(out).toBe('# Title\n\nBody');
51
- expect(out).not.toContain('| markdown |');
1
+ import { createHash } from 'node:crypto';
2
+ import { Writable } from 'node:stream';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { formatErrorEnvelope, formatOutput, render } from './output.js';
5
+ function sink(isTTY) {
6
+ let data = '';
7
+ const stream = new Writable({
8
+ write(chunk, _encoding, callback) {
9
+ data += String(chunk);
10
+ callback();
11
+ },
12
+ });
13
+ Object.defineProperty(stream, 'isTTY', { value: isTTY });
14
+ return { stream, text: () => data };
15
+ }
16
+ function rendered(data, options, isTTY) {
17
+ const stdout = sink(isTTY);
18
+ render(data, { ...options, stdout: stdout.stream });
19
+ return stdout.text();
20
+ }
21
+ describe('formatOutput', () => {
22
+ it.each([
23
+ { name: 'null', data: null, options: { fmt: 'json', fmtExplicit: true }, isTTY: false },
24
+ { name: 'empty arrays', data: [], options: { fmt: 'table', fmtExplicit: true }, isTTY: true },
25
+ { name: 'one scalar field', data: { value: 'hello' }, options: { fmt: 'plain', fmtExplicit: true }, isTTY: false },
26
+ {
27
+ name: 'multiple rows',
28
+ data: [{ name: 'alice', score: 10 }, { name: 'bob', score: 20 }],
29
+ options: { fmt: 'table', fmtExplicit: true, columns: ['name', 'score'] },
30
+ isTTY: true,
31
+ },
32
+ ])('renders $name byte-identically through the pure and stream APIs', ({ data, options, isTTY }) => {
33
+ expect(rendered(data, options, isTTY)).toBe(formatOutput(data, { ...options, isTTY }));
34
+ });
35
+ it('escapes commas, quotes, and newlines in CSV', () => {
36
+ expect(formatOutput([
37
+ { name: 'a,b', note: 'said "hello"' },
38
+ { name: 'plain', note: 'line 1\nline 2' },
39
+ ], { fmt: 'csv', fmtExplicit: true, columns: ['name', 'note'], isTTY: false })).toBe('name,note\n"a,b","said ""hello"""\nplain,"line 1\nline 2"\n');
40
+ });
41
+ it('preserves the legacy literal Markdown table cells', () => {
42
+ expect(formatOutput([
43
+ { name: 'a|b', note: 'line 1\nline 2' },
44
+ ], { fmt: 'md', fmtExplicit: true, columns: ['name', 'note'], isTTY: false })).toBe('| name | note |\n| --- | --- |\n| a|b | line 1\nline 2 |\n');
45
+ });
46
+ it('uses YAML for an implicit table format on a non-TTY stream', () => {
47
+ expect(rendered([{ name: 'alice', score: 10 }], {
48
+ fmt: 'table',
49
+ fmtExplicit: false,
50
+ columns: ['name', 'score'],
51
+ }, false)).toBe('- name: alice\n score: 10\n\n');
52
+ });
53
+ it('uses a table for an implicit table format on a TTY stream', () => {
54
+ const output = rendered([{ name: 'alice', score: 10 }], {
55
+ fmt: 'table',
56
+ fmtExplicit: false,
57
+ columns: ['name', 'score'],
58
+ }, true);
59
+ expect(output).toContain('alice');
60
+ expect(output).toContain('1 items');
61
+ expect(output).not.toContain('name: alice');
62
+ });
63
+ it('honors an explicit table format on a non-TTY stream', () => {
64
+ const output = rendered([{ name: 'alice' }], {
65
+ fmt: 'table',
66
+ fmtExplicit: true,
67
+ columns: ['name'],
68
+ }, false);
69
+ expect(output).toContain('alice');
70
+ expect(output).not.toContain('name: alice');
71
+ });
72
+ it('honors an explicit JSON format on a non-TTY stream', () => {
73
+ expect(rendered([{ name: 'alice' }], {
74
+ fmt: 'json',
75
+ fmtExplicit: true,
76
+ }, false)).toBe('[\n {\n "name": "alice"\n }\n]\n');
77
+ });
78
+ it('writes values larger than 1 MiB completely', () => {
79
+ const value = 'x'.repeat((1024 * 1024) + 17);
80
+ const expected = `${value}\n`;
81
+ const output = rendered({ value }, { fmt: 'plain', fmtExplicit: true }, false);
82
+ expect(output.length).toBe(expected.length);
83
+ expect(createHash('sha256').update(output).digest('hex'))
84
+ .toBe(createHash('sha256').update(expected).digest('hex'));
85
+ });
86
+ it('does not resolve stream rendering before the write callback and drain', async () => {
87
+ let release;
88
+ const stdout = new Writable({
89
+ highWaterMark: 1,
90
+ write(_chunk, _encoding, callback) {
91
+ release = callback;
92
+ },
93
+ });
94
+ let settled = false;
95
+ const rendering = render({ value: 'slow' }, {
96
+ fmt: 'plain',
97
+ fmtExplicit: true,
98
+ stdout,
99
+ }).then(() => { settled = true; });
100
+ await new Promise(resolve => setImmediate(resolve));
101
+ expect(settled).toBe(false);
102
+ release?.();
103
+ await rendering;
104
+ expect(settled).toBe(true);
105
+ });
106
+ it('shows elapsed time when elapsed is zero', () => {
107
+ expect(formatOutput([{ name: 'alice' }], {
108
+ fmt: 'table',
109
+ fmtExplicit: true,
110
+ columns: ['name'],
111
+ elapsed: 0,
112
+ isTTY: true,
113
+ })).toContain('0.0s');
114
+ });
115
+ it('prints single Markdown payloads without wrapping them in a table', () => {
116
+ expect(formatOutput([{ markdown: '# Title\n\nBody' }], {
117
+ fmt: 'md',
118
+ fmtExplicit: true,
119
+ isTTY: false,
120
+ })).toBe('# Title\n\nBody\n');
121
+ });
122
+ });
123
+ describe('formatErrorEnvelope', () => {
124
+ it('returns the local YAML envelope bytes without writing to stderr', () => {
125
+ expect(formatErrorEnvelope({
126
+ ok: false,
127
+ error: {
128
+ code: 'AUTH_REQUIRED',
129
+ message: 'Sign in first',
130
+ help: 'Run webcmd github login.',
131
+ exitCode: 77,
132
+ },
133
+ })).toBe([
134
+ 'ok: false',
135
+ 'error:',
136
+ ' code: AUTH_REQUIRED',
137
+ ' message: Sign in first',
138
+ ' help: Run webcmd github login.',
139
+ ' exitCode: 77',
140
+ '',
141
+ ].join('\n'));
52
142
  });
53
143
  });
@@ -18,6 +18,7 @@ function createMockPage(overrides = {}) {
18
18
  pressKey: vi.fn(),
19
19
  getFormState: vi.fn().mockResolvedValue({}),
20
20
  wait: vi.fn(),
21
+ sleep: vi.fn().mockResolvedValue(undefined),
21
22
  tabs: vi.fn().mockResolvedValue([]),
22
23
  selectTab: vi.fn(),
23
24
  networkRequests: vi.fn().mockResolvedValue([]),
@@ -30,6 +30,7 @@ function createMockPage(getCookies) {
30
30
  scrollTo: vi.fn(),
31
31
  getFormState: vi.fn().mockResolvedValue({}),
32
32
  wait: vi.fn(),
33
+ sleep: vi.fn().mockResolvedValue(undefined),
33
34
  tabs: vi.fn().mockResolvedValue([]),
34
35
  selectTab: vi.fn(),
35
36
  networkRequests: vi.fn().mockResolvedValue([]),
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { createProgram } from './cli.js';
6
+ describe('webcmd plugin create', () => {
7
+ const createdDirs = [];
8
+ afterEach(() => {
9
+ for (const dir of createdDirs)
10
+ fs.rmSync(dir, { recursive: true, force: true });
11
+ vi.restoreAllMocks();
12
+ process.exitCode = undefined;
13
+ });
14
+ it('writes author name and GitHub handle supplied as flags', async () => {
15
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-plugin-create-'));
16
+ fs.rmSync(dir, { recursive: true, force: true });
17
+ createdDirs.push(dir);
18
+ vi.spyOn(console, 'log').mockImplementation(() => { });
19
+ const program = createProgram('', '');
20
+ program.exitOverride();
21
+ await program.parseAsync([
22
+ 'node',
23
+ 'webcmd',
24
+ 'plugin',
25
+ 'create',
26
+ 'weather',
27
+ '--dir',
28
+ dir,
29
+ '--author-name',
30
+ 'Rishabh',
31
+ '--author-handle',
32
+ 'rishabhraj36',
33
+ ]);
34
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'webcmd-plugin.json'), 'utf-8'));
35
+ expect(manifest.author).toEqual({ name: 'Rishabh', handle: 'rishabhraj36' });
36
+ });
37
+ });
@@ -5,6 +5,12 @@
5
5
  * 1. Single plugin: repo root IS the plugin directory.
6
6
  * 2. Monorepo: repo contains multiple plugins declared in `plugins` field.
7
7
  */
8
+ export interface PluginAuthor {
9
+ /** Display name shown in generated catalogs. */
10
+ name: string;
11
+ /** GitHub username used to link the author profile. */
12
+ handle: string;
13
+ }
8
14
  export interface SubPluginEntry {
9
15
  /** Relative path from repo root to the sub-plugin directory. */
10
16
  path: string;
@@ -14,6 +20,8 @@ export interface SubPluginEntry {
14
20
  webcmd?: string;
15
21
  /** When true, this sub-plugin is skipped during install. */
16
22
  disabled?: boolean;
23
+ /** Plugin author attribution. */
24
+ author?: PluginAuthor;
17
25
  }
18
26
  export interface PluginManifest {
19
27
  /** Plugin name (single-plugin mode). */
@@ -24,10 +32,14 @@ export interface PluginManifest {
24
32
  webcmd?: string;
25
33
  /** Human-readable description. */
26
34
  description?: string;
35
+ /** Plugin author attribution. */
36
+ author?: PluginAuthor;
27
37
  /** Monorepo sub-plugins. Key = logical plugin name. */
28
38
  plugins?: Record<string, SubPluginEntry>;
29
39
  }
30
40
  export declare const MANIFEST_FILENAME = "webcmd-plugin.json";
41
+ /** Validate explicit plugin author metadata without making a GitHub API call. */
42
+ export declare function validatePluginAuthor(value: unknown, label?: string): PluginAuthor;
31
43
  /**
32
44
  * Read and parse webcmd-plugin.json from a directory.
33
45
  * Returns null if the file does not exist or is unparseable.
@@ -9,7 +9,23 @@ import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
10
  import { PKG_VERSION } from './version.js';
11
11
  export const MANIFEST_FILENAME = 'webcmd-plugin.json';
12
+ const GITHUB_HANDLE_PATTERN = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
12
13
  // ── Read / Validate ─────────────────────────────────────────────────────────
14
+ /** Validate explicit plugin author metadata without making a GitHub API call. */
15
+ export function validatePluginAuthor(value, label = 'author') {
16
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
17
+ throw new Error(`Invalid ${label}: expected an object`);
18
+ }
19
+ const author = value;
20
+ const name = typeof author.name === 'string' ? author.name.trim() : '';
21
+ if (!name)
22
+ throw new Error(`Invalid ${label}.name: expected a non-empty string`);
23
+ const handle = typeof author.handle === 'string' ? author.handle.trim() : '';
24
+ if (!GITHUB_HANDLE_PATTERN.test(handle)) {
25
+ throw new Error(`Invalid ${label}.handle: expected a valid GitHub username`);
26
+ }
27
+ return { name, handle };
28
+ }
13
29
  /**
14
30
  * Read and parse webcmd-plugin.json from a directory.
15
31
  * Returns null if the file does not exist or is unparseable.
@@ -5,7 +5,25 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
5
  import * as fs from 'node:fs';
6
6
  import * as path from 'node:path';
7
7
  import * as os from 'node:os';
8
- import { _readPluginManifest as readPluginManifest, _isMonorepo as isMonorepo, _getEnabledPlugins as getEnabledPlugins, _parseVersion as parseVersion, _satisfiesRange as satisfiesRange, MANIFEST_FILENAME, } from './plugin-manifest.js';
8
+ import { _readPluginManifest as readPluginManifest, _isMonorepo as isMonorepo, _getEnabledPlugins as getEnabledPlugins, _parseVersion as parseVersion, _satisfiesRange as satisfiesRange, MANIFEST_FILENAME, validatePluginAuthor, } from './plugin-manifest.js';
9
+ // ── Author metadata ─────────────────────────────────────────────────────────
10
+ describe('validatePluginAuthor', () => {
11
+ it('normalizes valid author metadata', () => {
12
+ expect(validatePluginAuthor({ name: ' Rishabh ', handle: 'rishabhraj36' })).toEqual({
13
+ name: 'Rishabh',
14
+ handle: 'rishabhraj36',
15
+ });
16
+ });
17
+ it('rejects missing author fields', () => {
18
+ expect(() => validatePluginAuthor({ name: '', handle: 'rishabhraj36' })).toThrow('author.name');
19
+ expect(() => validatePluginAuthor({ name: 'Rishabh', handle: '' })).toThrow('author.handle');
20
+ });
21
+ it('rejects malformed GitHub handles', () => {
22
+ expect(() => validatePluginAuthor({ name: 'Rishabh', handle: '-wrong' })).toThrow('author.handle');
23
+ expect(() => validatePluginAuthor({ name: 'Rishabh', handle: 'wrong--handle' })).toThrow('author.handle');
24
+ expect(() => validatePluginAuthor({ name: 'Rishabh', handle: `${'a'.repeat(39)}b` })).toThrow('author.handle');
25
+ });
26
+ });
9
27
  // ── readPluginManifest ──────────────────────────────────────────────────────
10
28
  describe('readPluginManifest', () => {
11
29
  let tmpDir;
@@ -11,11 +11,14 @@
11
11
  * greet.ts — sample TS command using func()
12
12
  * README.md — basic documentation
13
13
  */
14
+ import { type PluginAuthor } from './plugin-manifest.js';
14
15
  export interface ScaffoldOptions {
15
16
  /** Directory to create the plugin in. Defaults to `./<name>` */
16
17
  dir?: string;
17
18
  /** Plugin description */
18
19
  description?: string;
20
+ /** Required author attribution for the generated plugin manifest. */
21
+ author: PluginAuthor;
19
22
  }
20
23
  export interface ScaffoldResult {
21
24
  name: string;
@@ -25,4 +28,4 @@ export interface ScaffoldResult {
25
28
  /**
26
29
  * Create a new plugin scaffold directory.
27
30
  */
28
- export declare function createPluginScaffold(name: string, opts?: ScaffoldOptions): ScaffoldResult;
31
+ export declare function createPluginScaffold(name: string, opts: ScaffoldOptions): ScaffoldResult;
@@ -13,16 +13,18 @@
13
13
  */
14
14
  import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
+ import { validatePluginAuthor } from './plugin-manifest.js';
16
17
  import { PKG_VERSION } from './version.js';
17
18
  /**
18
19
  * Create a new plugin scaffold directory.
19
20
  */
20
- export function createPluginScaffold(name, opts = {}) {
21
+ export function createPluginScaffold(name, opts) {
21
22
  // Validate name
22
23
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
23
24
  throw new Error(`Invalid plugin name "${name}". ` +
24
25
  `Plugin names must start with a lowercase letter and contain only lowercase letters, digits, and hyphens.`);
25
26
  }
27
+ const author = validatePluginAuthor(opts.author);
26
28
  const targetDir = opts.dir
27
29
  ? path.resolve(opts.dir)
28
30
  : path.resolve(name);
@@ -37,6 +39,7 @@ export function createPluginScaffold(name, opts = {}) {
37
39
  version: '0.1.0',
38
40
  description: opts.description ?? `A webcmd plugin: ${name}`,
39
41
  webcmd: `>=${PKG_VERSION}`,
42
+ author,
40
43
  };
41
44
  writeFile(targetDir, 'webcmd-plugin.json', JSON.stringify(manifest, null, 2) + '\n');
42
45
  files.push('webcmd-plugin.json');
@@ -8,6 +8,7 @@ import * as path from 'node:path';
8
8
  import { createPluginScaffold } from './plugin-scaffold.js';
9
9
  describe('createPluginScaffold', () => {
10
10
  const createdDirs = [];
11
+ const author = { name: 'Rishabh', handle: 'rishabhraj36' };
11
12
  afterEach(() => {
12
13
  for (const dir of createdDirs) {
13
14
  try {
@@ -20,7 +21,7 @@ describe('createPluginScaffold', () => {
20
21
  it('creates all expected files', () => {
21
22
  const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
22
23
  createdDirs.push(dir);
23
- const result = createPluginScaffold('my-test', { dir });
24
+ const result = createPluginScaffold('my-test', { dir, author });
24
25
  expect(result.name).toBe('my-test');
25
26
  expect(result.dir).toBe(dir);
26
27
  expect(result.files).toContain('webcmd-plugin.json');
@@ -36,25 +37,27 @@ describe('createPluginScaffold', () => {
36
37
  it('generates valid webcmd-plugin.json', () => {
37
38
  const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
38
39
  createdDirs.push(dir);
39
- createPluginScaffold('test-manifest', { dir, description: 'Test desc' });
40
+ createPluginScaffold('test-manifest', { dir, description: 'Test desc', author });
40
41
  const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'webcmd-plugin.json'), 'utf-8'));
41
42
  expect(manifest.name).toBe('test-manifest');
42
43
  expect(manifest.version).toBe('0.1.0');
43
44
  expect(manifest.description).toBe('Test desc');
44
45
  expect(manifest.webcmd).toMatch(/^>=/);
46
+ expect(manifest.author).toEqual(author);
45
47
  });
46
48
  it('generates ESM package.json', () => {
47
49
  const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
48
50
  createdDirs.push(dir);
49
- createPluginScaffold('test-pkg', { dir });
51
+ createPluginScaffold('test-pkg', { dir, author });
50
52
  const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
51
53
  expect(pkg.type).toBe('module');
52
54
  expect(pkg.peerDependencies?.['@agentrhq/webcmd']).toBeDefined();
55
+ expect(pkg.author).toBeUndefined();
53
56
  });
54
57
  it('generates a TS sample that matches the current plugin API', () => {
55
58
  const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
56
59
  createdDirs.push(dir);
57
- createPluginScaffold('test-ts', { dir });
60
+ createPluginScaffold('test-ts', { dir, author });
58
61
  const tsSample = fs.readFileSync(path.join(dir, 'greet.ts'), 'utf-8');
59
62
  expect(tsSample).toContain(`import { cli, Strategy } from '@agentrhq/webcmd/registry';`);
60
63
  expect(tsSample).toContain(`strategy: Strategy.PUBLIC`);
@@ -65,19 +68,31 @@ describe('createPluginScaffold', () => {
65
68
  it('documents a supported local install flow', () => {
66
69
  const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
67
70
  createdDirs.push(dir);
68
- createPluginScaffold('test-readme', { dir });
71
+ createPluginScaffold('test-readme', { dir, author });
69
72
  const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf-8');
70
73
  expect(readme).toContain(`webcmd plugin install file://${dir}`);
71
74
  });
72
75
  it('rejects invalid names', () => {
73
- expect(() => createPluginScaffold('Bad_Name')).toThrow('Invalid plugin name');
74
- expect(() => createPluginScaffold('123start')).toThrow('Invalid plugin name');
76
+ expect(() => createPluginScaffold('Bad_Name', { author })).toThrow('Invalid plugin name');
77
+ expect(() => createPluginScaffold('123start', { author })).toThrow('Invalid plugin name');
78
+ });
79
+ it('rejects missing or malformed author metadata before creating files', () => {
80
+ const missingDir = path.join(os.tmpdir(), `webcmd-scaffold-missing-author-${Date.now()}`);
81
+ const malformedDir = path.join(os.tmpdir(), `webcmd-scaffold-malformed-author-${Date.now()}`);
82
+ createdDirs.push(missingDir, malformedDir);
83
+ expect(() => createPluginScaffold('missing-author', { dir: missingDir })).toThrow('author');
84
+ expect(() => createPluginScaffold('malformed-author', {
85
+ dir: malformedDir,
86
+ author: { name: 'Rishabh', handle: '-wrong' },
87
+ })).toThrow('author.handle');
88
+ expect(fs.existsSync(missingDir)).toBe(false);
89
+ expect(fs.existsSync(malformedDir)).toBe(false);
75
90
  });
76
91
  it('rejects non-empty directory', () => {
77
92
  const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
78
93
  createdDirs.push(dir);
79
94
  fs.mkdirSync(dir, { recursive: true });
80
95
  fs.writeFileSync(path.join(dir, 'existing.txt'), 'x');
81
- expect(() => createPluginScaffold('test', { dir })).toThrow('not empty');
96
+ expect(() => createPluginScaffold('test', { dir, author })).toThrow('not empty');
82
97
  });
83
98
  });
@@ -485,7 +485,10 @@ function installDependencies(dir) {
485
485
  if (!fs.existsSync(pkgJsonPath))
486
486
  return;
487
487
  try {
488
- execFileSync('npm', ['install', '--omit=dev'], {
488
+ // Plugin repositories and their transitive dependencies are untrusted.
489
+ // Webcmd adapters do not require install-time lifecycle scripts, so deny
490
+ // preinstall/install/postinstall execution with the user's privileges.
491
+ execFileSync('npm', ['install', '--omit=dev', '--ignore-scripts'], {
489
492
  cwd: dir,
490
493
  encoding: 'utf-8',
491
494
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -564,6 +564,19 @@ describe('installDependencies', () => {
564
564
  expect(() => _installDependencies(failingDir)).toThrow('npm install failed');
565
565
  fs.rmSync(tmpDir, { recursive: true, force: true });
566
566
  });
567
+ it('runs npm install with --ignore-scripts so untrusted lifecycle scripts cannot execute', () => {
568
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-plugin-scripts-'));
569
+ const pluginDir = path.join(tmpDir, 'plugin-c');
570
+ fs.mkdirSync(pluginDir, { recursive: true });
571
+ fs.writeFileSync(path.join(pluginDir, 'package.json'), JSON.stringify({ name: 'plugin-c' }));
572
+ _installDependencies(pluginDir);
573
+ expect(mockExecFileSync).toHaveBeenCalledTimes(1);
574
+ const [bin, args] = mockExecFileSync.mock.calls[0];
575
+ expect(bin).toBe('npm');
576
+ expect(args).toContain('install');
577
+ expect(args).toContain('--ignore-scripts');
578
+ fs.rmSync(tmpDir, { recursive: true, force: true });
579
+ });
567
580
  });
568
581
  describe('postInstallMonorepoLifecycle', () => {
569
582
  let repoDir;
@@ -2,6 +2,7 @@
2
2
  * Core registry: Strategy enum, Arg/CliCommand interfaces, cli() registration.
3
3
  */
4
4
  import type { IPage } from './types.js';
5
+ import type { FileArgumentContract } from './hosted/contract.js';
5
6
  export declare enum Strategy {
6
7
  PUBLIC = "public",
7
8
  LOCAL = "local",
@@ -18,6 +19,7 @@ export interface Arg {
18
19
  positional?: boolean;
19
20
  help?: string;
20
21
  choices?: string[];
22
+ file?: FileArgumentContract;
21
23
  }
22
24
  export type CommandArgs = Record<string, any>;
23
25
  export type BrowserCommandFunc = (page: IPage, kwargs: CommandArgs, debug?: boolean) => Promise<unknown>;
@@ -1,5 +1,8 @@
1
1
  export declare const RELEASE_NOTE_SECTIONS: readonly ["Highlights", "Improvements", "Fixes", "Adapters", "Reverts"];
2
2
  export type ReleaseNoteSection = typeof RELEASE_NOTE_SECTIONS[number];
3
+ export interface NormalizeReleaseNotesOptions {
4
+ context?: ReleaseContext;
5
+ }
3
6
  export interface PullRequestLabel {
4
7
  name: string;
5
8
  }
@@ -33,8 +36,10 @@ export interface CompareCommit {
33
36
  }
34
37
  export type GitRunner = (args: readonly string[]) => Promise<string>;
35
38
  export declare function releaseVersionFromTag(tag: string): string;
39
+ export declare function isMajorRelease(context: Pick<ReleaseContext, 'tag' | 'previousTag'>): boolean;
40
+ export declare function releaseContributorHandles(pullRequests: PullRequestDetails[]): string[];
36
41
  export declare function replaceChangelogReleaseNotes(changelog: string, tag: string, notes: string): string;
37
42
  export declare function extractPullRequestNumber(message: string): number | null;
38
43
  export declare function filterReleasePullRequests(prs: PullRequestDetails[]): PullRequestDetails[];
39
- export declare function normalizeReleaseNotes(raw: string): string;
44
+ export declare function normalizeReleaseNotes(raw: string, options?: NormalizeReleaseNotesOptions): string;
40
45
  export declare function buildReleaseNotesPrompt(context: ReleaseContext): string;