@agentrhq/webcmd 0.3.1 → 0.3.3

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 (56) hide show
  1. package/README.md +13 -6
  2. package/cli-manifest.json +11 -11
  3. package/clis/producthunt/browse.js +9 -2
  4. package/clis/producthunt/browser-commands.test.js +66 -0
  5. package/clis/producthunt/hot.js +3 -3
  6. package/clis/producthunt/utils.js +27 -0
  7. package/clis/producthunt/utils.test.js +8 -0
  8. package/clis/spotify/spotify.js +11 -11
  9. package/dist/src/browser/daemon-client.d.ts +1 -0
  10. package/dist/src/browser/daemon-client.js +25 -1
  11. package/dist/src/browser/daemon-client.test.js +86 -1
  12. package/dist/src/browser/daemon-transport.d.ts +2 -0
  13. package/dist/src/browser/protocol.d.ts +12 -1
  14. package/dist/src/browser/runtime/local-cloak/actions.d.ts +1 -0
  15. package/dist/src/browser/runtime/local-cloak/actions.js +9 -9
  16. package/dist/src/browser/runtime/local-cloak/provider.d.ts +1 -0
  17. package/dist/src/browser/runtime/local-cloak/provider.js +6 -3
  18. package/dist/src/browser/runtime/local-cloak/provider.test.js +1 -0
  19. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
  20. package/dist/src/browser/runtime/local-cloak/session-manager.js +95 -19
  21. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +235 -0
  22. package/dist/src/browser/runtime/provider.d.ts +1 -0
  23. package/dist/src/cli.js +36 -12
  24. package/dist/src/cli.test.js +5 -0
  25. package/dist/src/daemon/server.js +65 -24
  26. package/dist/src/daemon/server.test.js +211 -2
  27. package/dist/src/docs-sync-review-cli.test.js +1 -1
  28. package/dist/src/errors.d.ts +5 -0
  29. package/dist/src/errors.js +23 -0
  30. package/dist/src/errors.test.js +58 -1
  31. package/dist/src/execution.js +57 -6
  32. package/dist/src/execution.test.js +426 -2
  33. package/dist/src/hosted/availability.test.js +12 -1
  34. package/dist/src/hosted/client.js +3 -1
  35. package/dist/src/hosted/client.test.js +62 -0
  36. package/dist/src/hosted/main-lifecycle.test.js +66 -5
  37. package/dist/src/hosted/types.d.ts +1 -0
  38. package/dist/src/main.js +4 -0
  39. package/dist/src/package-bin-process.d.ts +13 -0
  40. package/dist/src/package-bin-process.js +24 -0
  41. package/dist/src/package-bin-process.test.d.ts +1 -0
  42. package/dist/src/package-bin-process.test.js +22 -0
  43. package/dist/src/session-lease.d.ts +82 -0
  44. package/dist/src/session-lease.js +163 -0
  45. package/dist/src/session-lease.test.d.ts +1 -0
  46. package/dist/src/session-lease.test.js +217 -0
  47. package/dist/src/skills.d.ts +8 -4
  48. package/dist/src/skills.js +30 -1
  49. package/dist/src/skills.test.js +40 -12
  50. package/hosted-contract.json +45 -34
  51. package/package.json +3 -2
  52. package/scripts/check-package-bin.mjs +7 -6
  53. package/scripts/collect-ci-diagnostics.ps1 +274 -0
  54. package/scripts/docs-sync-review.ts +1 -1
  55. package/skills/webcmd-adapter-author/SKILL.md +1 -1
  56. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -6
@@ -0,0 +1,217 @@
1
+ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
2
+ import { SESSION_LEASE_TTL_MS, SessionLeaseRegistry, clearDaemonRunContext, generateRunId, getDaemonRunContext, getSessionLeaseKey, isSessionLeaseCommand, isUnknownOutcomeError, runWithDaemonRunContext, setDaemonRunContext, } from './session-lease.js';
3
+ const T0 = 1_000_000;
4
+ describe('logical daemon run context', () => {
5
+ afterEach(() => {
6
+ const current = getDaemonRunContext();
7
+ if (current)
8
+ clearDaemonRunContext(current.runId);
9
+ vi.restoreAllMocks();
10
+ });
11
+ it('keeps one run id stable while a logical run is bound and generates a different id for the next run', () => {
12
+ vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
13
+ const firstRunId = generateRunId();
14
+ setDaemonRunContext({ runId: firstRunId, command: 'chatgpt ask', access: 'write' });
15
+ expect(getDaemonRunContext()?.runId).toBe(firstRunId);
16
+ expect(getDaemonRunContext()?.runId).toBe(firstRunId);
17
+ clearDaemonRunContext(firstRunId);
18
+ const secondRunId = generateRunId();
19
+ expect(secondRunId).not.toBe(firstRunId);
20
+ expect(firstRunId).toMatch(new RegExp(`^run_${process.pid}_1763000000000_\\d+$`));
21
+ });
22
+ it('does not let deferred cleanup from an older run clear a newer run context', () => {
23
+ setDaemonRunContext({ runId: 'run_111_1_1', command: 'chatgpt ask', access: 'write' });
24
+ setDaemonRunContext({ runId: 'run_222_2_2', command: 'claude ask', access: 'write' });
25
+ clearDaemonRunContext('run_111_1_1');
26
+ expect(getDaemonRunContext()).toEqual({
27
+ runId: 'run_222_2_2',
28
+ command: 'claude ask',
29
+ access: 'write',
30
+ });
31
+ clearDaemonRunContext('run_222_2_2');
32
+ expect(getDaemonRunContext()).toBeUndefined();
33
+ });
34
+ it('only accepts a concrete run context through the setter', () => {
35
+ expectTypeOf(setDaemonRunContext).parameter(0).toEqualTypeOf();
36
+ });
37
+ it('keeps overlapping async run contexts isolated across awaits', async () => {
38
+ let resumeFirst;
39
+ const firstGate = new Promise(resolve => { resumeFirst = resolve; });
40
+ const firstContext = {
41
+ runId: 'run_111_1_1',
42
+ command: 'first write',
43
+ access: 'write',
44
+ };
45
+ const secondContext = {
46
+ runId: 'run_222_2_2',
47
+ command: 'second write',
48
+ access: 'write',
49
+ };
50
+ const first = runWithDaemonRunContext(firstContext, async () => {
51
+ await firstGate;
52
+ return getDaemonRunContext();
53
+ });
54
+ const second = runWithDaemonRunContext(secondContext, async () => getDaemonRunContext());
55
+ expect(await second).toEqual(secondContext);
56
+ resumeFirst();
57
+ expect(await first).toEqual(firstContext);
58
+ expect(getDaemonRunContext()).toBeUndefined();
59
+ });
60
+ });
61
+ describe('isUnknownOutcomeError', () => {
62
+ it('recognizes public, daemon, and message codes case-insensitively', () => {
63
+ expect(isUnknownOutcomeError({ code: 'COMMAND_RESULT_UNKNOWN' })).toBe(true);
64
+ expect(isUnknownOutcomeError({ errorCode: 'Command_Lost' })).toBe(true);
65
+ expect(isUnknownOutcomeError(new Error('daemon returned RESULT_EVICTED'))).toBe(true);
66
+ expect(isUnknownOutcomeError({ daemonCode: 'attach_failed', message: 'ordinary failure' })).toBe(false);
67
+ });
68
+ it('walks causes and AggregateError members without looping on cycles', () => {
69
+ const cyclic = new Error('outer');
70
+ cyclic.cause = cyclic;
71
+ const aggregate = new AggregateError([
72
+ cyclic,
73
+ new Error('wrapped', { cause: { errorCode: 'COMMAND_LOST' } }),
74
+ ]);
75
+ expect(isUnknownOutcomeError(aggregate)).toBe(true);
76
+ expect(isUnknownOutcomeError(cyclic)).toBe(false);
77
+ });
78
+ });
79
+ describe('session lease partitions', () => {
80
+ it('partitions persistent writes by resolved profile, surface, and encoded site', () => {
81
+ const workChatgpt = getSessionLeaseKey('work', 'adapter', 'site:chatgpt');
82
+ expect(workChatgpt).toBe('work␟adapter␟site%3Achatgpt');
83
+ expect(workChatgpt).not.toBe(getSessionLeaseKey('personal', 'adapter', 'site:chatgpt'));
84
+ expect(workChatgpt).not.toBe(getSessionLeaseKey('work', 'adapter', 'site:claude'));
85
+ expect(workChatgpt).not.toBe(getSessionLeaseKey('work', 'browser', 'site:chatgpt'));
86
+ });
87
+ it('does not arbitrate reads, ephemeral sessions, raw browser operations, or incomplete identities', () => {
88
+ const eligible = {
89
+ surface: 'adapter',
90
+ siteSession: 'persistent',
91
+ access: 'write',
92
+ session: 'site:chatgpt',
93
+ runId: 'run_111_1_1',
94
+ };
95
+ expect(isSessionLeaseCommand(eligible)).toBe(true);
96
+ expect(isSessionLeaseCommand({ ...eligible, access: 'read' })).toBe(false);
97
+ expect(isSessionLeaseCommand({ ...eligible, siteSession: 'ephemeral' })).toBe(false);
98
+ expect(isSessionLeaseCommand({ ...eligible, surface: 'browser' })).toBe(false);
99
+ expect(isSessionLeaseCommand({ ...eligible, session: '' })).toBe(false);
100
+ expect(isSessionLeaseCommand({ ...eligible, runId: undefined })).toBe(false);
101
+ });
102
+ });
103
+ describe('SessionLeaseRegistry', () => {
104
+ const KEY = getSessionLeaseKey('work', 'adapter', 'site:chatgpt');
105
+ let now = T0;
106
+ beforeEach(() => {
107
+ now = T0;
108
+ });
109
+ function registry() {
110
+ return new SessionLeaseRegistry(() => now);
111
+ }
112
+ function acquire(registry, runId, key = KEY) {
113
+ return registry.acquire({ key, runId, command: 'chatgpt ask', pid: Number(runId.split('_')[1]) }, () => false);
114
+ }
115
+ it('acquires a free key and returns the live holder on a conflicting run without mutation', () => {
116
+ const leases = registry();
117
+ expect(acquire(leases, 'run_111_1_1')).toEqual({
118
+ acquired: true,
119
+ lease: expect.objectContaining({
120
+ key: KEY,
121
+ runId: 'run_111_1_1',
122
+ command: 'chatgpt ask',
123
+ pid: 111,
124
+ acquiredAt: T0,
125
+ heartbeatAt: T0,
126
+ }),
127
+ });
128
+ now += 1_000;
129
+ const conflict = acquire(leases, 'run_222_2_2');
130
+ expect(conflict).toEqual({
131
+ acquired: false,
132
+ holder: expect.objectContaining({ runId: 'run_111_1_1', heartbeatAt: T0 }),
133
+ });
134
+ expect(leases.list(() => false)).toEqual([
135
+ expect.objectContaining({ runId: 'run_111_1_1', heartbeatAt: T0 }),
136
+ ]);
137
+ });
138
+ it('recovers the holder pid from a generated run id when explicit metadata is absent', () => {
139
+ const leases = registry();
140
+ expect(leases.acquire({ key: KEY, runId: 'run_4242_1700000000000_7', command: 'chatgpt ask' }, () => false)).toEqual({
141
+ acquired: true,
142
+ lease: expect.objectContaining({ pid: 4242 }),
143
+ });
144
+ });
145
+ it.each([
146
+ 0,
147
+ -1,
148
+ 1.5,
149
+ Number.NaN,
150
+ Number.POSITIVE_INFINITY,
151
+ Number.MAX_SAFE_INTEGER + 1,
152
+ ])('omits a non-actionable explicit holder pid (%s)', (pid) => {
153
+ const leases = registry();
154
+ const result = leases.acquire({ key: KEY, runId: 'opaque-run-id', command: 'chatgpt ask', pid }, () => false);
155
+ expect(result.acquired).toBe(true);
156
+ if (result.acquired)
157
+ expect(result.lease).not.toHaveProperty('pid');
158
+ });
159
+ it.each([
160
+ 'run_0_1700000000000_1',
161
+ 'run_-1_1700000000000_1',
162
+ 'run_1.5_1700000000000_1',
163
+ 'run_NaN_1700000000000_1',
164
+ 'run_Infinity_1700000000000_1',
165
+ `run_${Number.MAX_SAFE_INTEGER + 1}_1700000000000_1`,
166
+ ])('does not recover a non-actionable holder pid from %s', (runId) => {
167
+ const leases = registry();
168
+ const result = leases.acquire({ key: KEY, runId, command: 'chatgpt ask' }, () => false);
169
+ expect(result.acquired).toBe(true);
170
+ if (result.acquired)
171
+ expect(result.lease).not.toHaveProperty('pid');
172
+ });
173
+ it('treats same-run acquisition and explicit heartbeat as owner-only liveness refreshes', () => {
174
+ const leases = registry();
175
+ acquire(leases, 'run_111_1_1');
176
+ now += SESSION_LEASE_TTL_MS;
177
+ const refreshed = acquire(leases, 'run_111_1_1');
178
+ expect(refreshed).toEqual({
179
+ acquired: true,
180
+ lease: expect.objectContaining({ acquiredAt: T0, heartbeatAt: now }),
181
+ });
182
+ now += 10;
183
+ expect(leases.heartbeat(KEY, 'run_999_9_9')).toBe(false);
184
+ expect(leases.heartbeat(KEY, 'run_111_1_1')).toBe(true);
185
+ expect(leases.list(() => false)[0]).toMatchObject({ acquiredAt: T0, heartbeatAt: now });
186
+ });
187
+ it('lets a challenger acquire after expiry', () => {
188
+ const leases = registry();
189
+ acquire(leases, 'run_111_1_1');
190
+ now += SESSION_LEASE_TTL_MS + 1;
191
+ expect(acquire(leases, 'run_222_2_2')).toEqual({
192
+ acquired: true,
193
+ lease: expect.objectContaining({ runId: 'run_222_2_2', acquiredAt: now }),
194
+ });
195
+ });
196
+ it('keeps an expired holder live while the holder still has pending work', () => {
197
+ const leases = registry();
198
+ acquire(leases, 'run_111_1_1');
199
+ now += SESSION_LEASE_TTL_MS + 10_000;
200
+ const conflict = leases.acquire({ key: KEY, runId: 'run_222_2_2', command: 'chatgpt ask', pid: 222 }, (runId) => runId === 'run_111_1_1');
201
+ expect(conflict).toEqual({
202
+ acquired: false,
203
+ holder: expect.objectContaining({ runId: 'run_111_1_1' }),
204
+ });
205
+ expect(leases.list((runId) => runId === 'run_111_1_1')).toHaveLength(1);
206
+ expect(leases.list(() => false)).toEqual([]);
207
+ });
208
+ it('releases only leases owned by the requested run and reports the count', () => {
209
+ const leases = registry();
210
+ acquire(leases, 'run_111_1_1');
211
+ acquire(leases, 'run_111_1_1', getSessionLeaseKey('work', 'adapter', 'site:claude'));
212
+ expect(leases.releaseByRunId('run_999_9_9')).toBe(0);
213
+ expect(leases.list(() => false)).toHaveLength(2);
214
+ expect(leases.releaseByRunId('run_111_1_1')).toBe(2);
215
+ expect(leases.list(() => false)).toEqual([]);
216
+ });
217
+ });
@@ -4,7 +4,7 @@ export interface WebcmdSkillInfo {
4
4
  version: string;
5
5
  path: string;
6
6
  }
7
- export interface WebcmdSkillInstallOptions {
7
+ export interface WebcmdSkillOptions {
8
8
  provider?: string;
9
9
  scope?: string;
10
10
  customPath?: string;
@@ -18,15 +18,19 @@ export interface WebcmdSkillLink {
18
18
  stableLink: string;
19
19
  destination?: string;
20
20
  }
21
- export interface WebcmdSkillInstallResult {
21
+ export interface WebcmdSkillAddResult {
22
22
  provider?: SkillProvider;
23
23
  scope?: SkillScope;
24
24
  skills: WebcmdSkillLink[];
25
25
  }
26
+ export interface WebcmdSkillRemoveResult {
27
+ removed: string[];
28
+ }
26
29
  type SkillProvider = 'agents' | 'codex' | 'claude';
27
30
  type SkillScope = 'user' | 'project';
28
31
  export declare function getSkillsRoot(packageRoot?: string): string;
29
32
  export declare function listWebcmdSkills(packageRoot?: string): WebcmdSkillInfo[];
30
- export declare function installWebcmdSkill(options?: WebcmdSkillInstallOptions): WebcmdSkillInstallResult;
31
- export declare function updateWebcmdSkill(options?: WebcmdSkillInstallOptions): WebcmdSkillInstallResult;
33
+ export declare function addWebcmdSkills(options?: WebcmdSkillOptions): WebcmdSkillAddResult;
34
+ export declare function updateWebcmdSkill(options?: WebcmdSkillOptions): WebcmdSkillAddResult;
35
+ export declare function removeWebcmdSkills(options?: WebcmdSkillOptions): WebcmdSkillRemoveResult;
32
36
  export {};
@@ -19,7 +19,7 @@ export function listWebcmdSkills(packageRoot) {
19
19
  .filter((entry) => entry !== null)
20
20
  .sort((a, b) => a.name.localeCompare(b.name));
21
21
  }
22
- export function installWebcmdSkill(options = {}) {
22
+ export function addWebcmdSkills(options = {}) {
23
23
  const provider = options.customPath === undefined ? normalizeProvider(options.provider) : undefined;
24
24
  const scope = normalizeScope(options.scope);
25
25
  const skills = updateStableSkillLinks(options)
@@ -46,6 +46,35 @@ export function updateWebcmdSkill(options = {}) {
46
46
  }),
47
47
  };
48
48
  }
49
+ export function removeWebcmdSkills(options = {}) {
50
+ const homeDir = options.homeDir ?? os.homedir();
51
+ const cwd = options.cwd ?? process.cwd();
52
+ const roots = new Set([
53
+ ...['.agents', '.codex', '.claude'].flatMap((dir) => [
54
+ path.join(homeDir, dir, 'skills'),
55
+ path.join(cwd, dir, 'skills'),
56
+ ]),
57
+ ...(options.customPath === undefined ? [] : [expandHomePath(options.customPath)]),
58
+ path.join(homeDir, '.webcmd', 'skills'),
59
+ ]);
60
+ const skills = listWebcmdSkills(options.packageRoot);
61
+ const removed = [];
62
+ for (const root of roots) {
63
+ for (const skill of skills) {
64
+ const linkPath = path.join(root, skill.name);
65
+ const current = safeLstat(linkPath);
66
+ if (!current)
67
+ continue;
68
+ if (!current.isSymbolicLink()) {
69
+ throw new ArgumentError(`Refusing to remove non-symlink path: ${linkPath}`, 'Remove it manually if it is no longer needed.');
70
+ }
71
+ removed.push(linkPath);
72
+ }
73
+ }
74
+ for (const linkPath of removed)
75
+ fs.unlinkSync(linkPath);
76
+ return { removed };
77
+ }
49
78
  function updateStableSkillLinks(options) {
50
79
  const skillsRoot = getSkillsRoot(options.packageRoot);
51
80
  const skills = listWebcmdSkills(options.packageRoot);
@@ -4,7 +4,7 @@ import * as path from 'node:path';
4
4
  import yaml from 'js-yaml';
5
5
  import { describe, expect, it } from 'vitest';
6
6
  import { ArgumentError } from './errors.js';
7
- import { installWebcmdSkill, listWebcmdSkills, updateWebcmdSkill } from './skills.js';
7
+ import { addWebcmdSkills, listWebcmdSkills, removeWebcmdSkills, updateWebcmdSkill } from './skills.js';
8
8
  function makePackageRoot(label = 'current') {
9
9
  const root = fs.mkdtempSync(path.join(os.tmpdir(), `webcmd-skills-${label}-`));
10
10
  fs.mkdirSync(path.join(root, 'skills', 'webcmd-browser'), { recursive: true });
@@ -80,18 +80,18 @@ describe('webcmd skills content', () => {
80
80
  'webcmd-usage',
81
81
  ]);
82
82
  });
83
- it('installs bundled skills once and refreshes them after package updates', () => {
83
+ it('adds bundled skills once and refreshes them after package updates', () => {
84
84
  const firstRoot = makePackageRoot('first');
85
85
  const secondRoot = makePackageRoot('second');
86
86
  const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-home-'));
87
87
  const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-project-'));
88
- const installed = installWebcmdSkill({ packageRoot: firstRoot, homeDir, cwd, provider: 'codex', scope: 'project' });
89
- expect(installed).toMatchObject({
88
+ const added = addWebcmdSkills({ packageRoot: firstRoot, homeDir, cwd, provider: 'codex', scope: 'project' });
89
+ expect(added).toMatchObject({
90
90
  provider: 'codex',
91
91
  scope: 'project',
92
92
  });
93
- expect(installed.skills.map((skill) => skill.name)).toEqual(['smart-search', 'webcmd-autofix', 'webcmd-browser']);
94
- for (const skill of installed.skills) {
93
+ expect(added.skills.map((skill) => skill.name)).toEqual(['smart-search', 'webcmd-autofix', 'webcmd-browser']);
94
+ for (const skill of added.skills) {
95
95
  expect(skill.source).toBe(path.join(firstRoot, 'skills', skill.name));
96
96
  expect(skill.stableLink).toBe(path.join(homeDir, '.webcmd', 'skills', skill.name));
97
97
  expect(skill.destination).toBe(path.join(cwd, '.codex', 'skills', skill.name));
@@ -99,22 +99,22 @@ describe('webcmd skills content', () => {
99
99
  }
100
100
  const updated = updateWebcmdSkill({ packageRoot: secondRoot, homeDir });
101
101
  expect(updated.skills.every((skill) => skill.destination === undefined)).toBe(true);
102
- for (const skill of installed.skills) {
102
+ for (const skill of added.skills) {
103
103
  expect(real(skill.destination)).toBe(real(path.join(secondRoot, 'skills', skill.name)));
104
104
  }
105
105
  });
106
- it('installs bundled skills into a custom skills directory', () => {
106
+ it('adds bundled skills into a custom skills directory', () => {
107
107
  const packageRoot = makePackageRoot();
108
108
  const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-home-'));
109
109
  const customPath = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-custom-skills-'));
110
- const installed = installWebcmdSkill({ packageRoot, homeDir, customPath });
111
- expect(installed.provider).toBeUndefined();
112
- expect(installed.skills.map((skill) => skill.destination)).toEqual([
110
+ const added = addWebcmdSkills({ packageRoot, homeDir, customPath });
111
+ expect(added.provider).toBeUndefined();
112
+ expect(added.skills.map((skill) => skill.destination)).toEqual([
113
113
  path.join(customPath, 'smart-search'),
114
114
  path.join(customPath, 'webcmd-autofix'),
115
115
  path.join(customPath, 'webcmd-browser'),
116
116
  ]);
117
- for (const skill of installed.skills) {
117
+ for (const skill of added.skills) {
118
118
  expect(real(skill.destination)).toBe(real(skill.source));
119
119
  }
120
120
  });
@@ -125,4 +125,32 @@ describe('webcmd skills content', () => {
125
125
  fs.mkdirSync(stablePath, { recursive: true });
126
126
  expect(() => updateWebcmdSkill({ packageRoot, homeDir })).toThrow(ArgumentError);
127
127
  });
128
+ it('removes bundled skill links from every supported location', () => {
129
+ const packageRoot = makePackageRoot();
130
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-home-'));
131
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-project-'));
132
+ const customPath = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-custom-skills-'));
133
+ for (const provider of ['agents', 'codex', 'claude']) {
134
+ addWebcmdSkills({ packageRoot, homeDir, cwd, provider, scope: 'user' });
135
+ addWebcmdSkills({ packageRoot, homeDir, cwd, provider, scope: 'project' });
136
+ }
137
+ addWebcmdSkills({ packageRoot, homeDir, cwd, customPath });
138
+ const result = removeWebcmdSkills({ packageRoot, homeDir, cwd, customPath });
139
+ expect(result.removed).toHaveLength(24);
140
+ for (const linkPath of result.removed) {
141
+ expect(() => fs.lstatSync(linkPath)).toThrow();
142
+ }
143
+ expect(removeWebcmdSkills({ packageRoot, homeDir, cwd, customPath })).toEqual({ removed: [] });
144
+ });
145
+ it('refuses removal before deleting any links when a destination is not a symlink', () => {
146
+ const packageRoot = makePackageRoot();
147
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-home-'));
148
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-project-'));
149
+ const added = addWebcmdSkills({ packageRoot, homeDir, cwd, provider: 'agents', scope: 'user' });
150
+ const blocker = path.join(cwd, '.codex', 'skills', 'smart-search');
151
+ fs.mkdirSync(blocker, { recursive: true });
152
+ expect(() => removeWebcmdSkills({ packageRoot, homeDir, cwd })).toThrow(ArgumentError);
153
+ expect(fs.lstatSync(added.skills[0].destination).isSymbolicLink()).toBe(true);
154
+ expect(fs.lstatSync(blocker).isDirectory()).toBe(true);
155
+ });
128
156
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "webcmdVersion": "0.3.1",
3
+ "webcmdVersion": "0.3.3",
4
4
  "outputFormats": [
5
5
  "table",
6
6
  "plain",
@@ -26634,7 +26634,7 @@
26634
26634
  "name": "auth",
26635
26635
  "description": "Authenticate with Spotify (OAuth — run once)",
26636
26636
  "access": "write",
26637
- "strategy": "PUBLIC",
26637
+ "strategy": "LOCAL",
26638
26638
  "browser": false,
26639
26639
  "positionals": [],
26640
26640
  "options": [],
@@ -26644,9 +26644,10 @@
26644
26644
  "aliases": [],
26645
26645
  "defaultFormat": "table",
26646
26646
  "fileArguments": [],
26647
- "sessionPolicy": "create-or-reuse",
26647
+ "sessionPolicy": "local-only",
26648
26648
  "availability": {
26649
- "mode": "hosted"
26649
+ "mode": "local-only",
26650
+ "reason": "local-tool"
26650
26651
  }
26651
26652
  },
26652
26653
  {
@@ -26655,7 +26656,7 @@
26655
26656
  "name": "next",
26656
26657
  "description": "Skip to next track",
26657
26658
  "access": "write",
26658
- "strategy": "PUBLIC",
26659
+ "strategy": "LOCAL",
26659
26660
  "browser": false,
26660
26661
  "positionals": [],
26661
26662
  "options": [],
@@ -26665,9 +26666,10 @@
26665
26666
  "aliases": [],
26666
26667
  "defaultFormat": "table",
26667
26668
  "fileArguments": [],
26668
- "sessionPolicy": "create-or-reuse",
26669
+ "sessionPolicy": "local-only",
26669
26670
  "availability": {
26670
- "mode": "hosted"
26671
+ "mode": "local-only",
26672
+ "reason": "local-tool"
26671
26673
  }
26672
26674
  },
26673
26675
  {
@@ -26676,7 +26678,7 @@
26676
26678
  "name": "pause",
26677
26679
  "description": "Pause playback",
26678
26680
  "access": "write",
26679
- "strategy": "PUBLIC",
26681
+ "strategy": "LOCAL",
26680
26682
  "browser": false,
26681
26683
  "positionals": [],
26682
26684
  "options": [],
@@ -26686,9 +26688,10 @@
26686
26688
  "aliases": [],
26687
26689
  "defaultFormat": "table",
26688
26690
  "fileArguments": [],
26689
- "sessionPolicy": "create-or-reuse",
26691
+ "sessionPolicy": "local-only",
26690
26692
  "availability": {
26691
- "mode": "hosted"
26693
+ "mode": "local-only",
26694
+ "reason": "local-tool"
26692
26695
  }
26693
26696
  },
26694
26697
  {
@@ -26697,7 +26700,7 @@
26697
26700
  "name": "play",
26698
26701
  "description": "Resume playback or search and play a track/artist",
26699
26702
  "access": "write",
26700
- "strategy": "PUBLIC",
26703
+ "strategy": "LOCAL",
26701
26704
  "browser": false,
26702
26705
  "positionals": [
26703
26706
  {
@@ -26719,9 +26722,10 @@
26719
26722
  "aliases": [],
26720
26723
  "defaultFormat": "table",
26721
26724
  "fileArguments": [],
26722
- "sessionPolicy": "create-or-reuse",
26725
+ "sessionPolicy": "local-only",
26723
26726
  "availability": {
26724
- "mode": "hosted"
26727
+ "mode": "local-only",
26728
+ "reason": "local-tool"
26725
26729
  }
26726
26730
  },
26727
26731
  {
@@ -26730,7 +26734,7 @@
26730
26734
  "name": "prev",
26731
26735
  "description": "Skip to previous track",
26732
26736
  "access": "write",
26733
- "strategy": "PUBLIC",
26737
+ "strategy": "LOCAL",
26734
26738
  "browser": false,
26735
26739
  "positionals": [],
26736
26740
  "options": [],
@@ -26740,9 +26744,10 @@
26740
26744
  "aliases": [],
26741
26745
  "defaultFormat": "table",
26742
26746
  "fileArguments": [],
26743
- "sessionPolicy": "create-or-reuse",
26747
+ "sessionPolicy": "local-only",
26744
26748
  "availability": {
26745
- "mode": "hosted"
26749
+ "mode": "local-only",
26750
+ "reason": "local-tool"
26746
26751
  }
26747
26752
  },
26748
26753
  {
@@ -26751,7 +26756,7 @@
26751
26756
  "name": "queue",
26752
26757
  "description": "Add a track to the playback queue",
26753
26758
  "access": "write",
26754
- "strategy": "PUBLIC",
26759
+ "strategy": "LOCAL",
26755
26760
  "browser": false,
26756
26761
  "positionals": [
26757
26762
  {
@@ -26772,9 +26777,10 @@
26772
26777
  "aliases": [],
26773
26778
  "defaultFormat": "table",
26774
26779
  "fileArguments": [],
26775
- "sessionPolicy": "create-or-reuse",
26780
+ "sessionPolicy": "local-only",
26776
26781
  "availability": {
26777
- "mode": "hosted"
26782
+ "mode": "local-only",
26783
+ "reason": "local-tool"
26778
26784
  }
26779
26785
  },
26780
26786
  {
@@ -26783,7 +26789,7 @@
26783
26789
  "name": "repeat",
26784
26790
  "description": "Set repeat mode (off / track / context)",
26785
26791
  "access": "write",
26786
- "strategy": "PUBLIC",
26792
+ "strategy": "LOCAL",
26787
26793
  "browser": false,
26788
26794
  "positionals": [
26789
26795
  {
@@ -26808,9 +26814,10 @@
26808
26814
  "aliases": [],
26809
26815
  "defaultFormat": "table",
26810
26816
  "fileArguments": [],
26811
- "sessionPolicy": "create-or-reuse",
26817
+ "sessionPolicy": "local-only",
26812
26818
  "availability": {
26813
- "mode": "hosted"
26819
+ "mode": "local-only",
26820
+ "reason": "local-tool"
26814
26821
  }
26815
26822
  },
26816
26823
  {
@@ -26819,7 +26826,7 @@
26819
26826
  "name": "search",
26820
26827
  "description": "Search for tracks",
26821
26828
  "access": "read",
26822
- "strategy": "PUBLIC",
26829
+ "strategy": "LOCAL",
26823
26830
  "browser": false,
26824
26831
  "positionals": [
26825
26832
  {
@@ -26851,9 +26858,10 @@
26851
26858
  "aliases": [],
26852
26859
  "defaultFormat": "table",
26853
26860
  "fileArguments": [],
26854
- "sessionPolicy": "create-or-reuse",
26861
+ "sessionPolicy": "local-only",
26855
26862
  "availability": {
26856
- "mode": "hosted"
26863
+ "mode": "local-only",
26864
+ "reason": "local-tool"
26857
26865
  }
26858
26866
  },
26859
26867
  {
@@ -26862,7 +26870,7 @@
26862
26870
  "name": "shuffle",
26863
26871
  "description": "Toggle shuffle on/off",
26864
26872
  "access": "write",
26865
- "strategy": "PUBLIC",
26873
+ "strategy": "LOCAL",
26866
26874
  "browser": false,
26867
26875
  "positionals": [
26868
26876
  {
@@ -26886,9 +26894,10 @@
26886
26894
  "aliases": [],
26887
26895
  "defaultFormat": "table",
26888
26896
  "fileArguments": [],
26889
- "sessionPolicy": "create-or-reuse",
26897
+ "sessionPolicy": "local-only",
26890
26898
  "availability": {
26891
- "mode": "hosted"
26899
+ "mode": "local-only",
26900
+ "reason": "local-tool"
26892
26901
  }
26893
26902
  },
26894
26903
  {
@@ -26897,7 +26906,7 @@
26897
26906
  "name": "status",
26898
26907
  "description": "Show current playback status",
26899
26908
  "access": "read",
26900
- "strategy": "PUBLIC",
26909
+ "strategy": "LOCAL",
26901
26910
  "browser": false,
26902
26911
  "positionals": [],
26903
26912
  "options": [],
@@ -26911,9 +26920,10 @@
26911
26920
  "aliases": [],
26912
26921
  "defaultFormat": "table",
26913
26922
  "fileArguments": [],
26914
- "sessionPolicy": "create-or-reuse",
26923
+ "sessionPolicy": "local-only",
26915
26924
  "availability": {
26916
- "mode": "hosted"
26925
+ "mode": "local-only",
26926
+ "reason": "local-tool"
26917
26927
  }
26918
26928
  },
26919
26929
  {
@@ -26922,7 +26932,7 @@
26922
26932
  "name": "volume",
26923
26933
  "description": "Set playback volume (0-100)",
26924
26934
  "access": "write",
26925
- "strategy": "PUBLIC",
26935
+ "strategy": "LOCAL",
26926
26936
  "browser": false,
26927
26937
  "positionals": [
26928
26938
  {
@@ -26942,9 +26952,10 @@
26942
26952
  "aliases": [],
26943
26953
  "defaultFormat": "table",
26944
26954
  "fileArguments": [],
26945
- "sessionPolicy": "create-or-reuse",
26955
+ "sessionPolicy": "local-only",
26946
26956
  "availability": {
26947
- "mode": "hosted"
26957
+ "mode": "local-only",
26958
+ "reason": "local-tool"
26948
26959
  }
26949
26960
  },
26950
26961
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentrhq/webcmd",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Turn websites, browser sessions, desktop apps, and local tools into deterministic CLI surfaces for humans and AI agents.",
5
5
  "engines": {
6
6
  "node": ">=20.0.0"
@@ -77,6 +77,7 @@
77
77
  ],
78
78
  "author": "AgentR",
79
79
  "license": "Apache-2.0",
80
+ "homepage": "https://docs.webcmd.dev",
80
81
  "repository": {
81
82
  "type": "git",
82
83
  "url": "git+https://github.com/agentrhq/webcmd.git"
@@ -112,4 +113,4 @@
112
113
  "overrides": {
113
114
  "postcss": "^8.5.10"
114
115
  }
115
- }
116
+ }