@agentrhq/webcmd 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/cli-manifest.json +228 -24
- package/clis/chatgpt/ask.js +1 -1
- package/clis/chatgpt/ask.test.js +11 -0
- package/clis/chatgpt/commands.test.js +102 -1
- package/clis/chatgpt/deep-research-result.js +45 -3
- package/clis/chatgpt/image.js +22 -2
- package/clis/chatgpt/project-file-add.js +24 -1
- package/clis/chatgpt/utils.js +258 -52
- package/clis/chatgpt/utils.test.js +259 -1
- package/clis/claude/ask.js +21 -1
- package/clis/facebook/feed.js +110 -24
- package/clis/facebook/feed.test.js +62 -0
- package/clis/facebook/search.js +185 -38
- package/clis/facebook/search.test.js +95 -50
- package/clis/instagram/explore.js +30 -12
- package/clis/instagram/explore.test.js +77 -0
- package/clis/instagram/post.js +14 -1
- package/clis/instagram/reel.js +13 -1
- package/clis/linkedin/company.js +153 -0
- package/clis/linkedin/company.test.js +111 -0
- package/clis/linkedin/connections.js +135 -0
- package/clis/linkedin/connections.test.js +141 -0
- package/clis/mercury/reimbursement-draft.js +12 -1
- package/clis/producthunt/hot.js +1 -2
- package/clis/spotify/spotify.js +11 -11
- package/clis/twitter/article-evaluate.test.js +27 -0
- package/clis/twitter/article.js +73 -16
- package/clis/twitter/article.test.js +209 -0
- package/clis/twitter/download.js +6 -1
- package/clis/twitter/post.js +14 -1
- package/clis/twitter/profile.js +1 -1
- package/clis/twitter/profile.test.js +8 -0
- package/clis/twitter/quote.js +11 -1
- package/clis/twitter/reply.js +11 -1
- package/dist/src/browser/base-page.d.ts +26 -0
- package/dist/src/browser/base-page.js +23 -8
- package/dist/src/browser/base-page.test.js +45 -12
- package/dist/src/browser/command-catalog.d.ts +6 -0
- package/dist/src/browser/command-catalog.js +259 -0
- package/dist/src/browser/command-catalog.test.d.ts +1 -0
- package/dist/src/browser/command-catalog.test.js +79 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +7 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +23 -9
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
- package/dist/src/browser/target-resolver.d.ts +1 -0
- package/dist/src/browser/target-resolver.js +99 -4
- package/dist/src/browser/target-resolver.test.js +141 -1
- package/dist/src/build-manifest.d.ts +6 -0
- package/dist/src/build-manifest.js +18 -1
- package/dist/src/build-manifest.test.js +57 -1
- package/dist/src/builtin-command-surface.d.ts +9 -0
- package/dist/src/builtin-command-surface.js +16 -0
- package/dist/src/check-hosted-contract.test.d.ts +1 -0
- package/dist/src/check-hosted-contract.test.js +56 -0
- package/dist/src/cli.js +58 -106
- package/dist/src/cli.test.js +71 -1
- package/dist/src/command-presentation.d.ts +95 -0
- package/dist/src/command-presentation.js +486 -0
- package/dist/src/command-presentation.test.d.ts +1 -0
- package/dist/src/command-presentation.test.js +97 -0
- package/dist/src/command-surface.d.ts +43 -0
- package/dist/src/command-surface.js +205 -0
- package/dist/src/command-surface.test.d.ts +1 -0
- package/dist/src/command-surface.test.js +406 -0
- package/dist/src/commanderAdapter.d.ts +5 -1
- package/dist/src/commanderAdapter.js +12 -55
- package/dist/src/commanderAdapter.test.js +15 -3
- package/dist/src/community-plugin-sync.d.ts +11 -0
- package/dist/src/community-plugin-sync.js +138 -0
- package/dist/src/community-plugin-sync.test.d.ts +1 -0
- package/dist/src/community-plugin-sync.test.js +123 -0
- package/dist/src/completion-fast.d.ts +4 -4
- package/dist/src/completion-fast.js +15 -34
- package/dist/src/completion-shared.d.ts +4 -0
- package/dist/src/completion-shared.js +38 -0
- package/dist/src/completion.js +3 -27
- package/dist/src/completion.test.js +31 -2
- package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
- package/dist/src/docs-sync-review-cli.test.js +322 -0
- package/dist/src/docs-sync-review.d.ts +117 -0
- package/dist/src/docs-sync-review.js +475 -0
- package/dist/src/docs-sync-review.test.d.ts +1 -0
- package/dist/src/docs-sync-review.test.js +324 -0
- package/dist/src/errors.d.ts +12 -3
- package/dist/src/errors.js +11 -7
- package/dist/src/errors.test.js +14 -1
- package/dist/src/execution.d.ts +1 -2
- package/dist/src/execution.js +4 -45
- package/dist/src/generate-release-notes-cli.test.js +7 -2
- package/dist/src/help.d.ts +4 -0
- package/dist/src/help.js +50 -255
- package/dist/src/help.test.js +27 -1
- package/dist/src/hosted/args.d.ts +2 -7
- package/dist/src/hosted/args.js +2 -99
- package/dist/src/hosted/args.test.js +15 -1
- package/dist/src/hosted/availability.d.ts +13 -0
- package/dist/src/hosted/availability.js +16 -0
- package/dist/src/hosted/availability.test.d.ts +1 -0
- package/dist/src/hosted/availability.test.js +191 -0
- package/dist/src/hosted/browser-args.d.ts +18 -0
- package/dist/src/hosted/browser-args.js +152 -0
- package/dist/src/hosted/browser-args.test.d.ts +1 -0
- package/dist/src/hosted/browser-args.test.js +182 -0
- package/dist/src/hosted/client.d.ts +31 -2
- package/dist/src/hosted/client.js +441 -53
- package/dist/src/hosted/client.test.js +651 -32
- package/dist/src/hosted/config.d.ts +7 -2
- package/dist/src/hosted/config.js +26 -4
- package/dist/src/hosted/config.test.js +34 -1
- package/dist/src/hosted/contract.d.ts +94 -0
- package/dist/src/hosted/contract.js +208 -0
- package/dist/src/hosted/contract.test.d.ts +1 -0
- package/dist/src/hosted/contract.test.js +361 -0
- package/dist/src/hosted/credentials.d.ts +38 -0
- package/dist/src/hosted/credentials.js +248 -0
- package/dist/src/hosted/credentials.test.d.ts +1 -0
- package/dist/src/hosted/credentials.test.js +93 -0
- package/dist/src/hosted/file-contract.test.d.ts +1 -0
- package/dist/src/hosted/file-contract.test.js +174 -0
- package/dist/src/hosted/files.d.ts +29 -0
- package/dist/src/hosted/files.js +296 -0
- package/dist/src/hosted/files.test.d.ts +1 -0
- package/dist/src/hosted/files.test.js +231 -0
- package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
- package/dist/src/hosted/main-lifecycle.test.js +213 -0
- package/dist/src/hosted/manifest.d.ts +5 -0
- package/dist/src/hosted/manifest.js +21 -66
- package/dist/src/hosted/manifest.test.js +120 -2
- package/dist/src/hosted/output-parity.test.d.ts +1 -0
- package/dist/src/hosted/output-parity.test.js +108 -0
- package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
- package/dist/src/hosted/root-command-surface.test.js +646 -0
- package/dist/src/hosted/runner.d.ts +7 -0
- package/dist/src/hosted/runner.js +554 -253
- package/dist/src/hosted/runner.test.js +1471 -53
- package/dist/src/hosted/setup.d.ts +3 -2
- package/dist/src/hosted/setup.js +45 -15
- package/dist/src/hosted/setup.test.js +133 -6
- package/dist/src/hosted/types.d.ts +101 -23
- package/dist/src/main.js +120 -108
- package/dist/src/manifest-types.d.ts +2 -0
- package/dist/src/output.d.ts +17 -2
- package/dist/src/output.js +88 -81
- package/dist/src/output.test.js +141 -51
- package/dist/src/pipeline/executor.test.js +1 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/plugin-create-cli.test.d.ts +1 -0
- package/dist/src/plugin-create-cli.test.js +37 -0
- package/dist/src/plugin-manifest.d.ts +12 -0
- package/dist/src/plugin-manifest.js +16 -0
- package/dist/src/plugin-manifest.test.js +19 -1
- package/dist/src/plugin-scaffold.d.ts +4 -1
- package/dist/src/plugin-scaffold.js +4 -1
- package/dist/src/plugin-scaffold.test.js +23 -8
- package/dist/src/plugin.js +4 -1
- package/dist/src/plugin.test.js +13 -0
- package/dist/src/registry.d.ts +2 -0
- package/dist/src/release-notes.js +12 -4
- package/dist/src/release-notes.test.js +27 -15
- package/dist/src/root-command-surface.d.ts +31 -0
- package/dist/src/root-command-surface.js +106 -0
- package/dist/src/serialization.d.ts +1 -16
- package/dist/src/serialization.js +5 -55
- package/dist/src/stream-write.d.ts +12 -0
- package/dist/src/stream-write.js +91 -0
- package/dist/src/stream-write.test.d.ts +1 -0
- package/dist/src/stream-write.test.js +186 -0
- package/dist/src/types.d.ts +5 -0
- package/dist/src/utils.d.ts +1 -1
- package/dist/src/utils.js +2 -8
- package/dist/src/utils.test.js +50 -0
- package/hosted-contract.json +36759 -0
- package/package.json +6 -1
- package/scripts/check-hosted-contract.mjs +108 -0
- package/scripts/docs-sync-review.ts +332 -0
- package/scripts/sync-community-plugins.ts +14 -0
- package/skills/webcmd-autofix/SKILL.md +26 -37
- package/skills/webcmd-usage/SKILL.md +7 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
|
|
3
|
+
import {
|
|
4
|
+
LINKEDIN_DOMAIN,
|
|
5
|
+
assertLinkedInAuthenticated,
|
|
6
|
+
normalizeWhitespace,
|
|
7
|
+
unwrapEvaluateResult,
|
|
8
|
+
} from './shared.js';
|
|
9
|
+
|
|
10
|
+
const SLUG_RE = /^[A-Za-z0-9%._-]+$/;
|
|
11
|
+
const COMPANY_URL_RE = /^\/company\/([^/?#]+)/;
|
|
12
|
+
const LINKEDIN_COMPANY_HOSTS = new Set(['linkedin.com', LINKEDIN_DOMAIN]);
|
|
13
|
+
|
|
14
|
+
function normalizeCompanyUrl(value) {
|
|
15
|
+
const raw = normalizeWhitespace(value);
|
|
16
|
+
if (!raw) throw new ArgumentError('LinkedIn company requires a company universal name or URL');
|
|
17
|
+
|
|
18
|
+
let slug = raw;
|
|
19
|
+
if (/^https?:\/\//i.test(raw) || raw.startsWith('/company/')) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = raw.startsWith('/') ? new URL(raw, `https://${LINKEDIN_DOMAIN}`) : new URL(raw);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new ArgumentError(`LinkedIn company received a malformed URL: ${raw}`);
|
|
25
|
+
}
|
|
26
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port
|
|
27
|
+
|| !LINKEDIN_COMPANY_HOSTS.has(parsed.hostname.toLowerCase())) {
|
|
28
|
+
throw new ArgumentError('LinkedIn company URL must point to linkedin.com');
|
|
29
|
+
}
|
|
30
|
+
const match = parsed.pathname.match(COMPANY_URL_RE);
|
|
31
|
+
if (!match) throw new ArgumentError('LinkedIn company URL must look like /company/<name>');
|
|
32
|
+
try {
|
|
33
|
+
slug = decodeURIComponent(match[1]);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new ArgumentError(`LinkedIn company URL has a malformed company slug: ${match[1]}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!SLUG_RE.test(slug)) {
|
|
39
|
+
throw new ArgumentError(`LinkedIn company name has unexpected characters: ${slug}`);
|
|
40
|
+
}
|
|
41
|
+
return `https://www.linkedin.com/company/${encodeURIComponent(slug)}/about/`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildCompanyExtractionScript() {
|
|
45
|
+
return String.raw`(() => {
|
|
46
|
+
const clean = (s) => String(s || '').replace(/[ ]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
47
|
+
const facts = {};
|
|
48
|
+
for (const dt of Array.from(document.querySelectorAll('dt'))) {
|
|
49
|
+
const key = clean(dt.innerText || dt.textContent || '').toLowerCase().replace(/:$/, '');
|
|
50
|
+
const dd = dt.nextElementSibling;
|
|
51
|
+
const val = dd ? clean(dd.innerText || dd.textContent || '') : '';
|
|
52
|
+
if (key && val && !(key in facts)) facts[key] = val;
|
|
53
|
+
}
|
|
54
|
+
const name = clean((document.querySelector('main h1') || document.querySelector('h1'))?.innerText || '');
|
|
55
|
+
const bodyText = clean(document.body ? (document.body.innerText || '') : '');
|
|
56
|
+
const followersMatch = bodyText.match(/([\d,]+)\s+followers/i);
|
|
57
|
+
const aboutHeading = Array.from(document.querySelectorAll('main h2, section h2'))
|
|
58
|
+
.find((el) => /^About$|^Overview$/i.test(clean(el.innerText || '')));
|
|
59
|
+
const aboutSection = aboutHeading ? aboutHeading.closest('section') : null;
|
|
60
|
+
const about = aboutSection
|
|
61
|
+
? clean((aboutSection.innerText || '').replace(/^About\s*/i, '').replace(/^Overview\s*/i, ''))
|
|
62
|
+
: '';
|
|
63
|
+
return {
|
|
64
|
+
url: window.location.href,
|
|
65
|
+
name,
|
|
66
|
+
industry: facts.industry || '',
|
|
67
|
+
size: facts['company size'] || '',
|
|
68
|
+
headquarters: facts.headquarters || '',
|
|
69
|
+
founded: facts.founded || '',
|
|
70
|
+
website: facts.website || '',
|
|
71
|
+
specialties: facts.specialties || '',
|
|
72
|
+
followers: followersMatch ? followersMatch[1].replace(/,/g, '') : '',
|
|
73
|
+
about: about.slice(0, 2000),
|
|
74
|
+
};
|
|
75
|
+
})()`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeCompanyOutputUrl(value, fallbackUrl) {
|
|
79
|
+
const raw = normalizeWhitespace(value || fallbackUrl);
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = new URL(raw, `https://${LINKEDIN_DOMAIN}`);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new CommandExecutionError('LinkedIn company extraction returned a malformed current URL');
|
|
85
|
+
}
|
|
86
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port
|
|
87
|
+
|| !LINKEDIN_COMPANY_HOSTS.has(parsed.hostname.toLowerCase())) {
|
|
88
|
+
throw new CommandExecutionError('LinkedIn company extraction ended on a non-LinkedIn page');
|
|
89
|
+
}
|
|
90
|
+
const match = parsed.pathname.match(COMPANY_URL_RE);
|
|
91
|
+
if (!match?.[1]) {
|
|
92
|
+
throw new CommandExecutionError('LinkedIn company extraction ended outside a company page');
|
|
93
|
+
}
|
|
94
|
+
let slug;
|
|
95
|
+
try {
|
|
96
|
+
slug = decodeURIComponent(match[1]);
|
|
97
|
+
} catch {
|
|
98
|
+
throw new CommandExecutionError('LinkedIn company extraction returned a malformed company slug');
|
|
99
|
+
}
|
|
100
|
+
return `https://${LINKEDIN_DOMAIN}/company/${encodeURIComponent(slug)}/about/`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeCompanyInfo(info, targetUrl) {
|
|
104
|
+
if (!info || typeof info !== 'object' || Array.isArray(info)) {
|
|
105
|
+
throw new CommandExecutionError('LinkedIn company extraction returned a malformed payload');
|
|
106
|
+
}
|
|
107
|
+
if (!info.name) {
|
|
108
|
+
throw new CommandExecutionError('LinkedIn company page rendered but no company name was found (layout drift or company not found)');
|
|
109
|
+
}
|
|
110
|
+
let followers = 0;
|
|
111
|
+
if (info.followers) {
|
|
112
|
+
followers = Number(info.followers);
|
|
113
|
+
if (!Number.isFinite(followers)) {
|
|
114
|
+
throw new CommandExecutionError('LinkedIn company extraction returned a malformed followers count');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
name: String(info.name),
|
|
119
|
+
industry: String(info.industry || ''),
|
|
120
|
+
size: String(info.size || ''),
|
|
121
|
+
headquarters: String(info.headquarters || ''),
|
|
122
|
+
founded: String(info.founded || ''),
|
|
123
|
+
website: String(info.website || ''),
|
|
124
|
+
specialties: String(info.specialties || ''),
|
|
125
|
+
followers,
|
|
126
|
+
about: String(info.about || ''),
|
|
127
|
+
url: normalizeCompanyOutputUrl(info.url, targetUrl),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
cli({
|
|
132
|
+
site: 'linkedin',
|
|
133
|
+
name: 'company',
|
|
134
|
+
access: 'read',
|
|
135
|
+
description: 'Read a LinkedIn company page: industry, size, HQ, founded, website, followers, and about text',
|
|
136
|
+
domain: LINKEDIN_DOMAIN,
|
|
137
|
+
strategy: Strategy.COOKIE,
|
|
138
|
+
browser: true,
|
|
139
|
+
args: [
|
|
140
|
+
{ name: 'company', type: 'string', required: true, positional: true, help: 'Company universal name, /company/<name> path, or full URL' },
|
|
141
|
+
],
|
|
142
|
+
columns: ['name', 'industry', 'size', 'headquarters', 'founded', 'website', 'specialties', 'followers', 'about', 'url'],
|
|
143
|
+
func: async (page, args) => {
|
|
144
|
+
const targetUrl = normalizeCompanyUrl(args.company);
|
|
145
|
+
await page.goto(targetUrl);
|
|
146
|
+
await page.wait(2);
|
|
147
|
+
await assertLinkedInAuthenticated(page, 'linkedin company');
|
|
148
|
+
const info = unwrapEvaluateResult(await page.evaluate(buildCompanyExtractionScript()));
|
|
149
|
+
return [normalizeCompanyInfo(info, targetUrl)];
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export const __test__ = { normalizeCompanyUrl, normalizeCompanyInfo, buildCompanyExtractionScript };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@agentrhq/webcmd/registry';
|
|
3
|
+
import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
|
|
4
|
+
import './company.js';
|
|
5
|
+
|
|
6
|
+
const { normalizeCompanyInfo, normalizeCompanyUrl } = await import('./company.js').then((module) => module.__test__);
|
|
7
|
+
|
|
8
|
+
function makePage(evaluateResult) {
|
|
9
|
+
return {
|
|
10
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
evaluate: vi.fn()
|
|
13
|
+
.mockResolvedValueOnce(false)
|
|
14
|
+
.mockResolvedValueOnce(evaluateResult),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('linkedin company', () => {
|
|
19
|
+
it('registers the company command', () => {
|
|
20
|
+
const command = getRegistry().get('linkedin/company');
|
|
21
|
+
expect(command).toMatchObject({
|
|
22
|
+
access: 'read',
|
|
23
|
+
browser: true,
|
|
24
|
+
strategy: 'cookie',
|
|
25
|
+
columns: ['name', 'industry', 'size', 'headquarters', 'founded', 'website', 'specialties', 'followers', 'about', 'url'],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('normalizes bare names, paths, and URLs to the about page', () => {
|
|
30
|
+
expect(normalizeCompanyUrl('nvidia')).toBe('https://www.linkedin.com/company/nvidia/about/');
|
|
31
|
+
expect(normalizeCompanyUrl('/company/nvidia')).toBe('https://www.linkedin.com/company/nvidia/about/');
|
|
32
|
+
expect(normalizeCompanyUrl('https://www.linkedin.com/company/databricks/'))
|
|
33
|
+
.toBe('https://www.linkedin.com/company/databricks/about/');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects invalid company identifiers as argument errors', () => {
|
|
37
|
+
for (const value of [
|
|
38
|
+
'',
|
|
39
|
+
'bad name!',
|
|
40
|
+
'https://www.linkedin.com/in/someone/',
|
|
41
|
+
'https://evil.example/company/nvidia/',
|
|
42
|
+
'https://www.linkedin.com/company/%E0%A4%A',
|
|
43
|
+
]) {
|
|
44
|
+
expect(() => normalizeCompanyUrl(value)).toThrow(ArgumentError);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('maps extracted company facts to a row', async () => {
|
|
49
|
+
const command = getRegistry().get('linkedin/company');
|
|
50
|
+
const page = makePage({
|
|
51
|
+
url: 'https://www.linkedin.com/company/nvidia/about/',
|
|
52
|
+
name: 'NVIDIA',
|
|
53
|
+
industry: 'Computer Hardware Manufacturing',
|
|
54
|
+
size: '10,001+ employees',
|
|
55
|
+
headquarters: 'Santa Clara, CA',
|
|
56
|
+
founded: '1993',
|
|
57
|
+
website: 'http://www.nvidia.com',
|
|
58
|
+
specialties: 'GPU, AI',
|
|
59
|
+
followers: '42040089',
|
|
60
|
+
about: 'Accelerated computing.',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await expect(command.func(page, { company: 'nvidia' })).resolves.toEqual([{
|
|
64
|
+
name: 'NVIDIA',
|
|
65
|
+
industry: 'Computer Hardware Manufacturing',
|
|
66
|
+
size: '10,001+ employees',
|
|
67
|
+
headquarters: 'Santa Clara, CA',
|
|
68
|
+
founded: '1993',
|
|
69
|
+
website: 'http://www.nvidia.com',
|
|
70
|
+
specialties: 'GPU, AI',
|
|
71
|
+
followers: 42040089,
|
|
72
|
+
about: 'Accelerated computing.',
|
|
73
|
+
url: 'https://www.linkedin.com/company/nvidia/about/',
|
|
74
|
+
}]);
|
|
75
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/company/nvidia/about/');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('fails closed when no company name is found', async () => {
|
|
79
|
+
const command = getRegistry().get('linkedin/company');
|
|
80
|
+
const page = makePage({ url: 'https://www.linkedin.com/company/ghost/about/', name: '' });
|
|
81
|
+
|
|
82
|
+
await expect(command.func(page, { company: 'ghost' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('normalizes emitted URLs and rejects non-company extraction URLs', () => {
|
|
86
|
+
expect(normalizeCompanyInfo({
|
|
87
|
+
url: 'https://www.linkedin.com/company/nvidia/posts/?trk=public_profile',
|
|
88
|
+
name: 'NVIDIA',
|
|
89
|
+
followers: '123',
|
|
90
|
+
}, 'https://www.linkedin.com/company/nvidia/about/')).toMatchObject({
|
|
91
|
+
url: 'https://www.linkedin.com/company/nvidia/about/',
|
|
92
|
+
followers: 123,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
for (const url of [
|
|
96
|
+
'https://www.linkedin.com/in/not-a-company/',
|
|
97
|
+
'https://evil.example/company/nvidia/',
|
|
98
|
+
]) {
|
|
99
|
+
expect(() => normalizeCompanyInfo({ url, name: 'NVIDIA' }, 'https://www.linkedin.com/company/nvidia/about/'))
|
|
100
|
+
.toThrow(CommandExecutionError);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rejects malformed follower counts', () => {
|
|
105
|
+
expect(() => normalizeCompanyInfo({
|
|
106
|
+
url: 'https://www.linkedin.com/company/nvidia/about/',
|
|
107
|
+
name: 'NVIDIA',
|
|
108
|
+
followers: 'many',
|
|
109
|
+
}, 'https://www.linkedin.com/company/nvidia/about/')).toThrow(CommandExecutionError);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
|
|
3
|
+
import {
|
|
4
|
+
LINKEDIN_DOMAIN,
|
|
5
|
+
assertLinkedInAuthenticated,
|
|
6
|
+
normalizeWhitespace,
|
|
7
|
+
parseLimit,
|
|
8
|
+
requireLinkedInCookie,
|
|
9
|
+
unwrapEvaluateResult,
|
|
10
|
+
} from './shared.js';
|
|
11
|
+
|
|
12
|
+
const CONNECTIONS_PATH = '/voyager/api/relationships/connections';
|
|
13
|
+
const PAGE_SIZE = 40;
|
|
14
|
+
|
|
15
|
+
async function fetchConnections(url, csrf) {
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(url, {
|
|
18
|
+
credentials: 'include',
|
|
19
|
+
headers: {
|
|
20
|
+
'csrf-token': csrf,
|
|
21
|
+
accept: 'application/json',
|
|
22
|
+
'x-restli-protocol-version': '2.0.0',
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
if (response.status === 401 || response.status === 403) {
|
|
26
|
+
return { authRequired: true, error: `HTTP ${response.status}` };
|
|
27
|
+
}
|
|
28
|
+
if (!response.ok) return { error: `HTTP ${response.status}` };
|
|
29
|
+
|
|
30
|
+
const contentType = response.headers?.get?.('content-type') || '';
|
|
31
|
+
if (/\btext\/html\b/i.test(contentType)) {
|
|
32
|
+
return { authRequired: true, error: 'HTML auth/checkpoint response' };
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return { json: await response.json() };
|
|
36
|
+
} catch {
|
|
37
|
+
return { error: 'response was not valid JSON' };
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return { error: `fetch failed: ${error?.message || String(error)}` };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function optionalText(value, field) {
|
|
45
|
+
if (value == null) return '';
|
|
46
|
+
if (typeof value !== 'string') {
|
|
47
|
+
throw new CommandExecutionError(`LinkedIn connection miniProfile field ${field} was malformed`);
|
|
48
|
+
}
|
|
49
|
+
return normalizeWhitespace(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mapConnection(element, index) {
|
|
53
|
+
const miniProfile = element?.miniProfile;
|
|
54
|
+
if (!miniProfile || typeof miniProfile !== 'object') {
|
|
55
|
+
throw new CommandExecutionError('LinkedIn connections returned an element without a miniProfile');
|
|
56
|
+
}
|
|
57
|
+
const publicId = optionalText(miniProfile.publicIdentifier, 'publicIdentifier');
|
|
58
|
+
if (!publicId || /[\s/?#]/.test(publicId)) {
|
|
59
|
+
throw new CommandExecutionError('LinkedIn connection element missing a stable public identifier');
|
|
60
|
+
}
|
|
61
|
+
const name = normalizeWhitespace([
|
|
62
|
+
optionalText(miniProfile.firstName, 'firstName'),
|
|
63
|
+
optionalText(miniProfile.lastName, 'lastName'),
|
|
64
|
+
].filter(Boolean).join(' ')) || publicId;
|
|
65
|
+
return {
|
|
66
|
+
rank: index + 1,
|
|
67
|
+
name,
|
|
68
|
+
occupation: optionalText(miniProfile.occupation, 'occupation'),
|
|
69
|
+
public_id: publicId,
|
|
70
|
+
connected_at: Number.isFinite(element.createdAt) ? element.createdAt : 0,
|
|
71
|
+
url: `https://www.linkedin.com/in/${encodeURIComponent(publicId)}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
cli({
|
|
76
|
+
site: 'linkedin',
|
|
77
|
+
name: 'connections',
|
|
78
|
+
access: 'read',
|
|
79
|
+
description: 'List your LinkedIn first-degree connections with names, headlines, and profile URLs',
|
|
80
|
+
domain: LINKEDIN_DOMAIN,
|
|
81
|
+
strategy: Strategy.COOKIE,
|
|
82
|
+
browser: true,
|
|
83
|
+
args: [
|
|
84
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of connections to return (max 500)' },
|
|
85
|
+
],
|
|
86
|
+
columns: ['rank', 'name', 'occupation', 'public_id', 'connected_at', 'url'],
|
|
87
|
+
func: async (page, args) => {
|
|
88
|
+
const limit = parseLimit(args.limit, 20, 500);
|
|
89
|
+
await page.goto('https://www.linkedin.com/mynetwork/invite-connect/connections/');
|
|
90
|
+
await page.wait(2);
|
|
91
|
+
await assertLinkedInAuthenticated(page, 'linkedin connections');
|
|
92
|
+
const csrf = await requireLinkedInCookie(page, 'linkedin connections');
|
|
93
|
+
const rows = [];
|
|
94
|
+
let start = 0;
|
|
95
|
+
|
|
96
|
+
while (rows.length < limit) {
|
|
97
|
+
const remaining = limit - rows.length;
|
|
98
|
+
const count = remaining < PAGE_SIZE ? remaining : PAGE_SIZE;
|
|
99
|
+
const url = `${CONNECTIONS_PATH}?start=${start}&count=${count}`;
|
|
100
|
+
const fetched = unwrapEvaluateResult(
|
|
101
|
+
await page.evaluate(`(${fetchConnections.toString()})(${JSON.stringify(url)}, ${JSON.stringify(csrf)})`),
|
|
102
|
+
);
|
|
103
|
+
if (fetched?.authRequired) {
|
|
104
|
+
throw new AuthRequiredError(
|
|
105
|
+
LINKEDIN_DOMAIN,
|
|
106
|
+
`LinkedIn connections API authentication failed: ${fetched.error}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (!fetched || fetched.error || !fetched.json) {
|
|
110
|
+
throw new CommandExecutionError(
|
|
111
|
+
`LinkedIn connections API returned an unexpected response: ${fetched?.error || 'no data'}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const elements = fetched.json.elements;
|
|
115
|
+
if (!Array.isArray(elements)) {
|
|
116
|
+
throw new CommandExecutionError('LinkedIn connections API returned a malformed payload: missing elements array');
|
|
117
|
+
}
|
|
118
|
+
if (elements.length === 0) break;
|
|
119
|
+
|
|
120
|
+
for (const element of elements) {
|
|
121
|
+
rows.push(mapConnection(element, rows.length));
|
|
122
|
+
if (rows.length >= limit) break;
|
|
123
|
+
}
|
|
124
|
+
start += elements.length;
|
|
125
|
+
if (elements.length < count) break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (rows.length === 0) {
|
|
129
|
+
throw new EmptyResultError('linkedin connections', 'No LinkedIn connections were found.');
|
|
130
|
+
}
|
|
131
|
+
return rows;
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
export const __test__ = { fetchConnections, mapConnection };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@agentrhq/webcmd/registry';
|
|
3
|
+
import {
|
|
4
|
+
ArgumentError,
|
|
5
|
+
AuthRequiredError,
|
|
6
|
+
CommandExecutionError,
|
|
7
|
+
EmptyResultError,
|
|
8
|
+
} from '@agentrhq/webcmd/errors';
|
|
9
|
+
import './connections.js';
|
|
10
|
+
|
|
11
|
+
const { mapConnection } = await import('./connections.js').then((module) => module.__test__);
|
|
12
|
+
|
|
13
|
+
function makePage({ evaluateResults = [false], cookies = [{ name: 'JSESSIONID', value: '"ajax:12345"' }] } = {}) {
|
|
14
|
+
const evaluate = vi.fn();
|
|
15
|
+
for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result);
|
|
16
|
+
return {
|
|
17
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
getCookies: vi.fn().mockResolvedValue(cookies),
|
|
20
|
+
evaluate,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const connection = (id) => ({
|
|
25
|
+
createdAt: 1700000000000 + id,
|
|
26
|
+
miniProfile: {
|
|
27
|
+
firstName: `First${id}`,
|
|
28
|
+
lastName: `Last${id}`,
|
|
29
|
+
occupation: `Job ${id}`,
|
|
30
|
+
publicIdentifier: `user${id}`,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('linkedin connections', () => {
|
|
35
|
+
it('registers the connections command', () => {
|
|
36
|
+
const command = getRegistry().get('linkedin/connections');
|
|
37
|
+
expect(command).toMatchObject({
|
|
38
|
+
access: 'read',
|
|
39
|
+
browser: true,
|
|
40
|
+
strategy: 'cookie',
|
|
41
|
+
columns: ['rank', 'name', 'occupation', 'public_id', 'connected_at', 'url'],
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('maps a connection element to a row', () => {
|
|
46
|
+
expect(mapConnection(connection(1), 0)).toEqual({
|
|
47
|
+
rank: 1,
|
|
48
|
+
name: 'First1 Last1',
|
|
49
|
+
occupation: 'Job 1',
|
|
50
|
+
public_id: 'user1',
|
|
51
|
+
connected_at: 1700000000001,
|
|
52
|
+
url: 'https://www.linkedin.com/in/user1',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('requires a miniProfile with a stable public identity', () => {
|
|
57
|
+
expect(() => mapConnection({ createdAt: 1 }, 0)).toThrow(CommandExecutionError);
|
|
58
|
+
expect(() => mapConnection({
|
|
59
|
+
createdAt: 1,
|
|
60
|
+
miniProfile: { firstName: 'Only', lastName: 'Name' },
|
|
61
|
+
}, 0)).toThrow(CommandExecutionError);
|
|
62
|
+
expect(() => mapConnection({
|
|
63
|
+
createdAt: 1,
|
|
64
|
+
miniProfile: { firstName: 'Bad', lastName: 'Id', publicIdentifier: 'bad/id' },
|
|
65
|
+
}, 0)).toThrow(CommandExecutionError);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('fails closed for malformed miniProfile scalar fields', () => {
|
|
69
|
+
expect(() => mapConnection({
|
|
70
|
+
createdAt: 1,
|
|
71
|
+
miniProfile: { firstName: { text: 'Alice' }, lastName: 'Example', publicIdentifier: 'alice' },
|
|
72
|
+
}, 0)).toThrow(CommandExecutionError);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns rows from the Voyager connections API', async () => {
|
|
76
|
+
const command = getRegistry().get('linkedin/connections');
|
|
77
|
+
const page = makePage({ evaluateResults: [false, { json: { elements: [connection(1), connection(2)] } }] });
|
|
78
|
+
|
|
79
|
+
const rows = await command.func(page, { limit: 2 });
|
|
80
|
+
|
|
81
|
+
expect(rows.map((row) => row.public_id)).toEqual(['user1', 'user2']);
|
|
82
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('/voyager/api/relationships/connections?start=0&count=2');
|
|
83
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('ajax:12345');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('paginates with bounded page sizes', async () => {
|
|
87
|
+
const command = getRegistry().get('linkedin/connections');
|
|
88
|
+
const firstPage = Array.from({ length: 40 }, (_, index) => connection(index + 1));
|
|
89
|
+
const page = makePage({
|
|
90
|
+
evaluateResults: [
|
|
91
|
+
false,
|
|
92
|
+
{ json: { elements: firstPage } },
|
|
93
|
+
{ json: { elements: [connection(41)] } },
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const rows = await command.func(page, { limit: 41 });
|
|
98
|
+
|
|
99
|
+
expect(rows).toHaveLength(41);
|
|
100
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('?start=0&count=40');
|
|
101
|
+
expect(page.evaluate.mock.calls[2][0]).toContain('?start=40&count=1');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('maps page and API authentication failures to AuthRequiredError', async () => {
|
|
105
|
+
const command = getRegistry().get('linkedin/connections');
|
|
106
|
+
await expect(command.func(makePage({ evaluateResults: [true] }), { limit: 2 }))
|
|
107
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
108
|
+
await expect(command.func(makePage({ evaluateResults: [false], cookies: [] }), { limit: 2 }))
|
|
109
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
110
|
+
|
|
111
|
+
for (const error of ['HTTP 403', 'HTML auth/checkpoint response']) {
|
|
112
|
+
const page = makePage({ evaluateResults: [false, { authRequired: true, error }] });
|
|
113
|
+
await expect(command.func(page, { limit: 2 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('fails closed for malformed API responses', async () => {
|
|
118
|
+
const command = getRegistry().get('linkedin/connections');
|
|
119
|
+
for (const result of [
|
|
120
|
+
{ error: 'response was not valid JSON' },
|
|
121
|
+
{ json: {} },
|
|
122
|
+
]) {
|
|
123
|
+
const page = makePage({ evaluateResults: [false, result] });
|
|
124
|
+
await expect(command.func(page, { limit: 2 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('throws EmptyResultError when no connections are found', async () => {
|
|
129
|
+
const command = getRegistry().get('linkedin/connections');
|
|
130
|
+
const page = makePage({ evaluateResults: [false, { json: { elements: [] } }] });
|
|
131
|
+
|
|
132
|
+
await expect(command.func(page, { limit: 2 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects invalid limits', async () => {
|
|
136
|
+
const command = getRegistry().get('linkedin/connections');
|
|
137
|
+
for (const limit of [0, 501, 1.5]) {
|
|
138
|
+
await expect(command.func(makePage(), { limit })).rejects.toBeInstanceOf(ArgumentError);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -26,7 +26,18 @@ cli({
|
|
|
26
26
|
defaultWindowMode: 'foreground',
|
|
27
27
|
navigateBefore: false,
|
|
28
28
|
args: [
|
|
29
|
-
{
|
|
29
|
+
{
|
|
30
|
+
name: 'receipt',
|
|
31
|
+
required: true,
|
|
32
|
+
help: 'Local receipt/proof file path',
|
|
33
|
+
file: {
|
|
34
|
+
direction: 'input',
|
|
35
|
+
pathKind: 'file',
|
|
36
|
+
multiple: false,
|
|
37
|
+
contentTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'],
|
|
38
|
+
maxBytes: 26_214_400,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
30
41
|
{ name: 'amount', required: true, help: 'Original-currency amount, e.g. 140.00' },
|
|
31
42
|
{ name: 'currency', default: 'CNY', help: 'Original currency code' },
|
|
32
43
|
{ name: 'date', required: true, help: 'Expense date as YYYY-MM-DD' },
|
package/clis/producthunt/hot.js
CHANGED
|
@@ -19,9 +19,8 @@ cli({
|
|
|
19
19
|
columns: ['rank', 'name', 'votes', 'url'],
|
|
20
20
|
func: async (page, args) => {
|
|
21
21
|
const count = Math.min(Number(args.limit) || 20, 50);
|
|
22
|
-
await page.installInterceptor('producthunt.com');
|
|
23
22
|
await page.goto('https://www.producthunt.com');
|
|
24
|
-
await page.
|
|
23
|
+
await page.wait(2);
|
|
25
24
|
const domItems = await page.evaluate(`
|
|
26
25
|
(() => {
|
|
27
26
|
const seen = new Set();
|
package/clis/spotify/spotify.js
CHANGED
|
@@ -108,7 +108,7 @@ cli({
|
|
|
108
108
|
name: 'auth',
|
|
109
109
|
access: 'write',
|
|
110
110
|
description: 'Authenticate with Spotify (OAuth — run once)',
|
|
111
|
-
strategy: Strategy.
|
|
111
|
+
strategy: Strategy.LOCAL,
|
|
112
112
|
browser: false,
|
|
113
113
|
args: [],
|
|
114
114
|
columns: ['status'],
|
|
@@ -175,7 +175,7 @@ cli({
|
|
|
175
175
|
name: 'status',
|
|
176
176
|
access: 'read',
|
|
177
177
|
description: 'Show current playback status',
|
|
178
|
-
strategy: Strategy.
|
|
178
|
+
strategy: Strategy.LOCAL,
|
|
179
179
|
browser: false,
|
|
180
180
|
args: [],
|
|
181
181
|
columns: ['track', 'artist', 'album', 'status', 'progress'],
|
|
@@ -197,7 +197,7 @@ cli({
|
|
|
197
197
|
name: 'play',
|
|
198
198
|
access: 'write',
|
|
199
199
|
description: 'Resume playback or search and play a track/artist',
|
|
200
|
-
strategy: Strategy.
|
|
200
|
+
strategy: Strategy.LOCAL,
|
|
201
201
|
browser: false,
|
|
202
202
|
args: [{ name: 'query', type: 'str', default: '', positional: true, help: 'Track or artist to play (optional)' }],
|
|
203
203
|
columns: ['track', 'artist', 'status'],
|
|
@@ -216,7 +216,7 @@ cli({
|
|
|
216
216
|
name: 'pause',
|
|
217
217
|
access: 'write',
|
|
218
218
|
description: 'Pause playback',
|
|
219
|
-
strategy: Strategy.
|
|
219
|
+
strategy: Strategy.LOCAL,
|
|
220
220
|
browser: false,
|
|
221
221
|
args: [],
|
|
222
222
|
columns: ['status'],
|
|
@@ -227,7 +227,7 @@ cli({
|
|
|
227
227
|
name: 'next',
|
|
228
228
|
access: 'write',
|
|
229
229
|
description: 'Skip to next track',
|
|
230
|
-
strategy: Strategy.
|
|
230
|
+
strategy: Strategy.LOCAL,
|
|
231
231
|
browser: false,
|
|
232
232
|
args: [],
|
|
233
233
|
columns: ['status'],
|
|
@@ -238,7 +238,7 @@ cli({
|
|
|
238
238
|
name: 'prev',
|
|
239
239
|
access: 'write',
|
|
240
240
|
description: 'Skip to previous track',
|
|
241
|
-
strategy: Strategy.
|
|
241
|
+
strategy: Strategy.LOCAL,
|
|
242
242
|
browser: false,
|
|
243
243
|
args: [],
|
|
244
244
|
columns: ['status'],
|
|
@@ -249,7 +249,7 @@ cli({
|
|
|
249
249
|
name: 'volume',
|
|
250
250
|
access: 'write',
|
|
251
251
|
description: 'Set playback volume (0-100)',
|
|
252
|
-
strategy: Strategy.
|
|
252
|
+
strategy: Strategy.LOCAL,
|
|
253
253
|
browser: false,
|
|
254
254
|
args: [{ name: 'level', type: 'int', default: 50, positional: true, required: true, help: 'Volume 0–100' }],
|
|
255
255
|
columns: ['volume'],
|
|
@@ -266,7 +266,7 @@ cli({
|
|
|
266
266
|
name: 'search',
|
|
267
267
|
access: 'read',
|
|
268
268
|
description: 'Search for tracks',
|
|
269
|
-
strategy: Strategy.
|
|
269
|
+
strategy: Strategy.LOCAL,
|
|
270
270
|
browser: false,
|
|
271
271
|
args: [
|
|
272
272
|
{ name: 'query', type: 'str', required: true, positional: true, help: 'Search query' },
|
|
@@ -287,7 +287,7 @@ cli({
|
|
|
287
287
|
name: 'queue',
|
|
288
288
|
access: 'write',
|
|
289
289
|
description: 'Add a track to the playback queue',
|
|
290
|
-
strategy: Strategy.
|
|
290
|
+
strategy: Strategy.LOCAL,
|
|
291
291
|
browser: false,
|
|
292
292
|
args: [{ name: 'query', type: 'str', required: true, positional: true, help: 'Track to add to queue' }],
|
|
293
293
|
columns: ['track', 'artist', 'status'],
|
|
@@ -302,7 +302,7 @@ cli({
|
|
|
302
302
|
name: 'shuffle',
|
|
303
303
|
access: 'write',
|
|
304
304
|
description: 'Toggle shuffle on/off',
|
|
305
|
-
strategy: Strategy.
|
|
305
|
+
strategy: Strategy.LOCAL,
|
|
306
306
|
browser: false,
|
|
307
307
|
args: [{ name: 'state', type: 'str', default: 'on', positional: true, choices: ['on', 'off'], help: 'on or off' }],
|
|
308
308
|
columns: ['shuffle'],
|
|
@@ -316,7 +316,7 @@ cli({
|
|
|
316
316
|
name: 'repeat',
|
|
317
317
|
access: 'write',
|
|
318
318
|
description: 'Set repeat mode (off / track / context)',
|
|
319
|
-
strategy: Strategy.
|
|
319
|
+
strategy: Strategy.LOCAL,
|
|
320
320
|
browser: false,
|
|
321
321
|
args: [{ name: 'mode', type: 'str', default: 'context', positional: true, choices: ['off', 'track', 'context'], help: 'off / track / context' }],
|
|
322
322
|
columns: ['repeat'],
|