@coldiq/mcp 0.3.14 → 0.3.16
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/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +0 -28
- package/dist/registry.js.map +1 -1
- package/dist/skills.d.ts +20 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +89 -0
- package/dist/skills.js.map +1 -0
- package/dist/tools/list-skills.d.ts +17 -0
- package/dist/tools/list-skills.d.ts.map +1 -0
- package/dist/tools/list-skills.js +22 -0
- package/dist/tools/list-skills.js.map +1 -0
- package/dist/tools/load-skill.d.ts +20 -0
- package/dist/tools/load-skill.d.ts.map +1 -0
- package/dist/tools/load-skill.js +29 -0
- package/dist/tools/load-skill.js.map +1 -0
- package/dist/tools/search-ads.d.ts +2 -6
- package/dist/tools/search-ads.d.ts.map +1 -1
- package/dist/tools/search-ads.js +4 -13
- package/dist/tools/search-ads.js.map +1 -1
- package/dist/utils/provider-display.d.ts.map +1 -1
- package/dist/utils/provider-display.js +2 -3
- package/dist/utils/provider-display.js.map +1 -1
- package/dist/utils/provider-resolver.js +0 -1
- package/dist/utils/provider-resolver.js.map +1 -1
- package/dist/utils/selection-insight.d.ts.map +1 -1
- package/dist/utils/selection-insight.js +0 -1
- package/dist/utils/selection-insight.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +16 -0
- package/src/registry.ts +0 -24
- package/src/skills.ts +108 -0
- package/src/tools/list-skills.ts +25 -0
- package/src/tools/load-skill.ts +32 -0
- package/src/tools/search-ads.ts +4 -14
- package/src/utils/provider-display.ts +2 -3
- package/src/utils/provider-resolver.ts +0 -1
- package/src/utils/selection-insight.ts +0 -1
- package/tests/registry.test.ts +3 -49
- package/tests/skills.test.ts +130 -0
- package/tests/tools/list-skills.test.ts +44 -0
- package/tests/tools/load-skill.test.ts +53 -0
- package/tests/utils/provider-display.test.ts +2 -2
package/src/skills.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ColdIQ GTM skills, served over MCP.
|
|
2
|
+
//
|
|
3
|
+
// Skills are step-by-step playbooks authored in the coldiq-marketplace-skills
|
|
4
|
+
// repo. Claude Code and Cursor load them natively (progressive disclosure), but
|
|
5
|
+
// agents without a skills loader (Codex, Windsurf, Cline, …) can't. Since every
|
|
6
|
+
// agent that installs this MCP gets these two tools, the MCP is the universal
|
|
7
|
+
// delivery channel: list_skills surfaces the catalog, load_skill fetches one
|
|
8
|
+
// playbook on demand.
|
|
9
|
+
//
|
|
10
|
+
// Source is the repo's discovery index on GitHub raw, so a push to the skills
|
|
11
|
+
// repo updates what every agent sees — no MCP republish needed. Override with
|
|
12
|
+
// COLDIQ_SKILLS_INDEX_URL.
|
|
13
|
+
|
|
14
|
+
const DEFAULT_INDEX_URL =
|
|
15
|
+
'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json'
|
|
16
|
+
const INDEX_TTL_MS = 10 * 60 * 1000 // re-fetch the catalog at most every 10 min
|
|
17
|
+
|
|
18
|
+
export interface SkillEntry {
|
|
19
|
+
name: string
|
|
20
|
+
description: string
|
|
21
|
+
url: string
|
|
22
|
+
digest?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CachedIndex {
|
|
26
|
+
skills: SkillEntry[]
|
|
27
|
+
fetchedAt: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let _indexCache: CachedIndex | null = null
|
|
31
|
+
const _bodyCache = new Map<string, string>()
|
|
32
|
+
|
|
33
|
+
/** Test seam: clear in-memory caches. */
|
|
34
|
+
export function __resetSkillsCache(): void {
|
|
35
|
+
_indexCache = null
|
|
36
|
+
_bodyCache.clear()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function indexUrl(): string {
|
|
40
|
+
return process.env.COLDIQ_SKILLS_INDEX_URL || DEFAULT_INDEX_URL
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fetchText(url: string): Promise<string> {
|
|
44
|
+
const res = await fetch(url, { headers: { 'user-agent': 'coldiq-mcp' } })
|
|
45
|
+
if (!res.ok) throw new Error(`request to ${url} failed (HTTP ${res.status})`)
|
|
46
|
+
return await res.text()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getIndex(): Promise<SkillEntry[]> {
|
|
50
|
+
if (_indexCache && Date.now() - _indexCache.fetchedAt < INDEX_TTL_MS) {
|
|
51
|
+
return _indexCache.skills
|
|
52
|
+
}
|
|
53
|
+
const parsed = JSON.parse(await fetchText(indexUrl()))
|
|
54
|
+
const raw = Array.isArray(parsed?.skills) ? parsed.skills : []
|
|
55
|
+
const skills: SkillEntry[] = raw
|
|
56
|
+
.filter((s: unknown): s is Record<string, unknown> => !!s && typeof s === 'object')
|
|
57
|
+
.map((s: Record<string, unknown>) => ({
|
|
58
|
+
name: String(s.name ?? ''),
|
|
59
|
+
description: String(s.description ?? ''),
|
|
60
|
+
url: String(s.url ?? ''),
|
|
61
|
+
digest: typeof s.digest === 'string' ? s.digest : undefined,
|
|
62
|
+
}))
|
|
63
|
+
.filter((s: SkillEntry) => s.name && s.url)
|
|
64
|
+
_indexCache = { skills, fetchedAt: Date.now() }
|
|
65
|
+
return skills
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The catalog: each skill's name + one-line description (no bodies). */
|
|
69
|
+
export async function listSkills(): Promise<{ name: string; description: string }[]> {
|
|
70
|
+
const skills = await getIndex()
|
|
71
|
+
return skills.map((s) => ({ name: s.name, description: s.description }))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Full playbook body for `name`. Any `resources/<file>.md` the skill references
|
|
76
|
+
* is fetched and appended so the returned document is self-contained (agents
|
|
77
|
+
* reached over MCP can't follow the relative links themselves).
|
|
78
|
+
*/
|
|
79
|
+
export async function loadSkill(name: string): Promise<string> {
|
|
80
|
+
const skills = await getIndex()
|
|
81
|
+
const entry = skills.find((s) => s.name === name)
|
|
82
|
+
if (!entry) {
|
|
83
|
+
throw new Error(`unknown skill "${name}". Available: ${skills.map((s) => s.name).join(', ')}`)
|
|
84
|
+
}
|
|
85
|
+
if (_bodyCache.has(entry.url)) return _bodyCache.get(entry.url) as string
|
|
86
|
+
|
|
87
|
+
const body = await fetchText(entry.url)
|
|
88
|
+
const refs = new Set<string>()
|
|
89
|
+
for (const m of body.matchAll(/resources\/([A-Za-z0-9._-]+\.md)/g)) refs.add(m[1])
|
|
90
|
+
|
|
91
|
+
let combined = body
|
|
92
|
+
if (refs.size > 0) {
|
|
93
|
+
const baseDir = entry.url.replace(/\/[^/]*$/, '') // strip trailing SKILL.md
|
|
94
|
+
const sections: string[] = []
|
|
95
|
+
for (const ref of refs) {
|
|
96
|
+
try {
|
|
97
|
+
const text = await fetchText(`${baseDir}/resources/${ref}`)
|
|
98
|
+
sections.push(`\n\n---\n\n## Resource: resources/${ref}\n\n${text.trim()}`)
|
|
99
|
+
} catch {
|
|
100
|
+
// A missing resource shouldn't fail the whole skill load.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
combined = body.replace(/\s+$/, '') + sections.join('') + '\n'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_bodyCache.set(entry.url, combined)
|
|
107
|
+
return combined
|
|
108
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { listSkills } from '../skills.js'
|
|
2
|
+
|
|
3
|
+
export const listSkillsName = 'list_skills'
|
|
4
|
+
|
|
5
|
+
export const listSkillsDescription =
|
|
6
|
+
'List the available ColdIQ GTM playbooks ("skills") — step-by-step expert workflows for go-to-market ' +
|
|
7
|
+
'tasks: building a TAM, Apollo / company / people search, contact enrichment and email waterfalls, ' +
|
|
8
|
+
'signal detection, ICP & personas, cold-email copy, campaign delivery, and more. Returns each skill\'s ' +
|
|
9
|
+
'name and a one-line description of when to use it. Call this when starting a prospecting / outreach / ' +
|
|
10
|
+
'GTM task to check for a relevant playbook, then call load_skill to read the full step-by-step guide. ' +
|
|
11
|
+
'(Claude Code and Cursor load these skills natively; on other agents this is how you reach them.)'
|
|
12
|
+
|
|
13
|
+
export const listSkillsSchema = {}
|
|
14
|
+
|
|
15
|
+
export async function listSkillsHandler(_input: Record<string, unknown>) {
|
|
16
|
+
try {
|
|
17
|
+
const skills = await listSkills()
|
|
18
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ data: skills }) }] }
|
|
19
|
+
} catch (err) {
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Could not load the ColdIQ skills catalog: ${(err as Error).message}` }) }],
|
|
22
|
+
isError: true,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { loadSkill } from '../skills.js'
|
|
3
|
+
|
|
4
|
+
export const loadSkillName = 'load_skill'
|
|
5
|
+
|
|
6
|
+
export const loadSkillDescription =
|
|
7
|
+
'Load the full ColdIQ GTM playbook ("skill") by name — the complete step-by-step expert workflow, ' +
|
|
8
|
+
'including which ColdIQ tools/endpoints to call at each step. Call list_skills first to get the available ' +
|
|
9
|
+
'names. The returned guide (with any referenced reference docs inlined) tells you exactly how to run the ' +
|
|
10
|
+
'task using the other ColdIQ tools.'
|
|
11
|
+
|
|
12
|
+
export const loadSkillSchema = {
|
|
13
|
+
name: z
|
|
14
|
+
.string()
|
|
15
|
+
.describe('The skill name from list_skills, e.g. "contact-enrichment", "apollo-search", or "tam-scoring".'),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loadSkillHandler(input: Record<string, unknown>) {
|
|
19
|
+
const name = String(input.name ?? '').trim()
|
|
20
|
+
if (!name) {
|
|
21
|
+
return {
|
|
22
|
+
content: [{ type: 'text' as const, text: 'Provide a skill "name" — call list_skills to see the available skills.' }],
|
|
23
|
+
isError: true,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const body = await loadSkill(name)
|
|
28
|
+
return { content: [{ type: 'text' as const, text: body }] }
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return { content: [{ type: 'text' as const, text: (err as Error).message }], isError: true }
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/tools/search-ads.ts
CHANGED
|
@@ -5,17 +5,17 @@ import { getProvidersForCapability } from '../utils/provider-resolver.js'
|
|
|
5
5
|
export const searchAdsName = 'search_ads'
|
|
6
6
|
|
|
7
7
|
export const searchAdsDescription =
|
|
8
|
-
'Search live ad creatives across
|
|
8
|
+
'Search live ad creatives across 4 ad libraries (Google Ads Transparency, LinkedIn Ad Library, Meta Ads Library, Twitter/X Ads) — a high-signal GTM input for competitive intelligence, ICP refinement, and pitch personalization. Routes by input: domains/advertiser_ids → Google only; search_urls → LinkedIn only; bare query → Google → Meta → Twitter waterfall. Use platform="google"|"linkedin"|"meta"|"twitter" to pin to one platform. All providers are async (~10–60s). Cost: ~5 credits per call (Twitter charges 1 credit per ad returned). Credits are fully refunded when a run returns zero ads. NOTE: Google Ads creatives return image URLs + creative IDs, not ad copy text — open the image URLs to read the ad. There is no "currently running only" filter; results can span past campaigns.'
|
|
9
9
|
|
|
10
10
|
export const searchAdsSchema = {
|
|
11
|
-
query: z.string().optional().describe('Advertiser/company name or keyword. Routes to Google→Meta→Twitter
|
|
11
|
+
query: z.string().optional().describe('Advertiser/company name or keyword. Routes to Google→Meta→Twitter when no platform-specific input is set.'),
|
|
12
12
|
|
|
13
13
|
domains: z.array(z.string()).optional().describe('Company domains (e.g. ["salesforce.com"]). Routes to Google Ads only.'),
|
|
14
14
|
advertiser_ids: z.array(z.string()).optional().describe('Google Ads Transparency advertiser IDs (e.g. ["AR16735076323512287233"]). Routes to Google Ads only.'),
|
|
15
15
|
|
|
16
16
|
search_urls: z.array(z.string()).optional().describe('Pre-built LinkedIn Ad Library URLs from linkedin.com/ad-library. Routes to LinkedIn only. Build at linkedin.com/ad-library/search using accountOwner/countries/dateOption filters.'),
|
|
17
17
|
|
|
18
|
-
country: z.string().optional().describe('ISO country code (2-letter for Meta/Twitter; longer accepted by Google as region).
|
|
18
|
+
country: z.string().optional().describe('ISO country code (2-letter for Meta/Twitter; longer accepted by Google as region).'),
|
|
19
19
|
max_results: z.number().int().min(1).max(200).default(25).describe('Max ads per provider call (1–200). Per-provider caps: Google 1000, LinkedIn 200, Meta 200, Twitter 100. Twitter charges 1 credit per ad returned.'),
|
|
20
20
|
|
|
21
21
|
ad_type: z.enum(['ALL', 'POLITICAL_AND_ISSUE_ADS']).optional().describe('Meta only. Defaults to ALL.'),
|
|
@@ -23,17 +23,7 @@ export const searchAdsSchema = {
|
|
|
23
23
|
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Twitter only. Date range start, YYYY-MM-DD.'),
|
|
24
24
|
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Twitter only. Date range end, YYYY-MM-DD.'),
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
'TECH_B2C', 'TECH_B2B', 'EDUCATION', 'ENTERTAINMENT', 'HEALTH_AND_BEAUTY',
|
|
28
|
-
'GAMING', 'EMPLOYMENT', 'RETAIL_AND_ECOMMERCE', 'AUTO', 'FINANCIAL_SERVICES',
|
|
29
|
-
'TRAVEL', 'REAL_ESTATE', 'GAMBLING_AND_FANTASY_SPORTS', 'POLITICS_AND_GOVERNMENT',
|
|
30
|
-
'CONSUMER_PACKAGED_GOODS', 'OTHER',
|
|
31
|
-
]).optional().describe('Reddit only. Filter by industry category.'),
|
|
32
|
-
budget_category: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional().describe('Reddit only.'),
|
|
33
|
-
post_type: z.enum(['IMAGE', 'VIDEO', 'TEXT']).optional().describe('Reddit only.'),
|
|
34
|
-
objective_type: z.enum(['AWARENESS', 'CONVERSIONS', 'APP_INSTALLS', 'TRAFFIC', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('Reddit only.'),
|
|
35
|
-
|
|
36
|
-
platform: z.enum(['google', 'linkedin', 'meta', 'twitter', 'reddit']).optional().describe('Pin to one platform; skips all others. Useful for cross-platform comparison via separate calls.'),
|
|
26
|
+
platform: z.enum(['google', 'linkedin', 'meta', 'twitter']).optional().describe('Pin to one platform; skips all others. Useful for cross-platform comparison via separate calls.'),
|
|
37
27
|
use_providers: z.array(z.string()).optional().describe(`Optional ordered list of providers to use. Leave empty to let ColdIQ automatically pick the best tool for your inputs — recommended for most use cases. Available providers: ${getProvidersForCapability('search_ads').join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
|
|
38
28
|
}
|
|
39
29
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
//
|
|
5
5
|
// Internal provider IDs (registry.ts + the find_emails waterfall) are a mix of
|
|
6
|
-
// clean vendor names ("apollo"), already-branded slugs ("
|
|
6
|
+
// clean vendor names ("apollo"), already-branded slugs ("linkedin_ad_library"), and ugly
|
|
7
7
|
// internal slugs ("limadata-work-email"). User-facing surfaces — the selection
|
|
8
8
|
// insight and `_meta.provider_name` — must show a clean branded label instead.
|
|
9
9
|
//
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// the ColdIQ "we picked the best tool" intelligence on display. The ONLY thing
|
|
12
12
|
// that must never surface is the Apify backend — and since no ID contains the
|
|
13
13
|
// word "Apify", that constraint is satisfied by mapping the Apify-backed scrapers
|
|
14
|
-
// to their branded data-source name (e.g. "
|
|
14
|
+
// to their branded data-source name (e.g. "LinkedIn Ad Library").
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
|
|
17
17
|
const DISPLAY_NAMES: Record<string, string> = {
|
|
@@ -96,7 +96,6 @@ const DISPLAY_NAMES: Record<string, string> = {
|
|
|
96
96
|
linkedin_ad_library: 'LinkedIn Ad Library',
|
|
97
97
|
meta_ads: 'Meta Ads',
|
|
98
98
|
twitter_ads: 'X Ads',
|
|
99
|
-
reddit_ads: 'Reddit Ads',
|
|
100
99
|
|
|
101
100
|
// --- search_places / reviews ---------------------------------------------
|
|
102
101
|
google_maps: 'Google Maps',
|
|
@@ -268,7 +268,6 @@ const GATED_DESCRIPTIONS: Partial<Record<Capability, Partial<Record<string, Gate
|
|
|
268
268
|
linkedin_ad_library: { kind: 'requires', fields: 'search_urls (pre-built LinkedIn Ad Library URLs)' },
|
|
269
269
|
meta_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
|
|
270
270
|
twitter_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
|
|
271
|
-
reddit_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
|
|
272
271
|
},
|
|
273
272
|
// -------------------------------------------------------------------------
|
|
274
273
|
search_places: {
|
|
@@ -148,7 +148,6 @@ const STRENGTHS: Partial<Record<InsightCapability, Record<string, string>>> = {
|
|
|
148
148
|
linkedin_ad_library: 'LinkedIn Ad Library coverage',
|
|
149
149
|
meta_ads: 'Meta (Facebook/Instagram) ad-library coverage',
|
|
150
150
|
twitter_ads: 'X ad coverage',
|
|
151
|
-
reddit_ads: 'Reddit ad coverage',
|
|
152
151
|
},
|
|
153
152
|
search_places: {
|
|
154
153
|
openmart: 'rich local-business data across the US, CA, AU, PR and NZ',
|
package/tests/registry.test.ts
CHANGED
|
@@ -1493,9 +1493,9 @@ describe('registry', () => {
|
|
|
1493
1493
|
})
|
|
1494
1494
|
|
|
1495
1495
|
describe('search_ads', () => {
|
|
1496
|
-
it('providers are ordered google_ads → linkedin_ad_library → meta_ads → twitter_ads
|
|
1496
|
+
it('providers are ordered google_ads → linkedin_ad_library → meta_ads → twitter_ads', () => {
|
|
1497
1497
|
const ids = getProviders('search_ads').map((p) => p.id)
|
|
1498
|
-
expect(ids).toEqual(['google_ads', 'linkedin_ad_library', 'meta_ads', 'twitter_ads'
|
|
1498
|
+
expect(ids).toEqual(['google_ads', 'linkedin_ad_library', 'meta_ads', 'twitter_ads'])
|
|
1499
1499
|
})
|
|
1500
1500
|
|
|
1501
1501
|
it('google_ads is async POST to /google-ads/search', () => {
|
|
@@ -1537,17 +1537,6 @@ describe('registry', () => {
|
|
|
1537
1537
|
expect(t.async!.pollEndpoint('8')).toBe('/twitter-ads-scraper/search/8')
|
|
1538
1538
|
})
|
|
1539
1539
|
|
|
1540
|
-
it('reddit_ads is async POST to /reddit-ads/scrape', () => {
|
|
1541
|
-
const r = getProviders('search_ads').find((p) => p.id === 'reddit_ads')!
|
|
1542
|
-
expect(r.method).toBe('POST')
|
|
1543
|
-
expect(r.endpoint).toBe('/reddit-ads/scrape')
|
|
1544
|
-
expect(r.async!.pollEndpoint('5')).toBe('/reddit-ads/scrape/5')
|
|
1545
|
-
expect(r.async!.isComplete({ status: 'done' })).toBe(true)
|
|
1546
|
-
expect(r.async!.isComplete({ status: 'failed' })).toBe(true)
|
|
1547
|
-
expect(r.async!.isComplete({ status: 'timed_out' })).toBe(true)
|
|
1548
|
-
expect(r.async!.isComplete({ status: 'processing' })).toBe(false)
|
|
1549
|
-
})
|
|
1550
|
-
|
|
1551
1540
|
it('google_ads.isApplicable: true for query/domains/advertiser_ids, false for empty or wrong platform', () => {
|
|
1552
1541
|
const g = getProviders('search_ads').find((p) => p.id === 'google_ads')!
|
|
1553
1542
|
expect(g.isApplicable!({ query: 'Salesforce' })).toBe(true)
|
|
@@ -1591,18 +1580,6 @@ describe('registry', () => {
|
|
|
1591
1580
|
expect(t.isApplicable!({ query: 'ColdIQ', platform: 'twitter' })).toBe(true)
|
|
1592
1581
|
})
|
|
1593
1582
|
|
|
1594
|
-
it('reddit_ads.isApplicable: true for empty/query, false when domains/advertiser_ids/search_urls set', () => {
|
|
1595
|
-
const r = getProviders('search_ads').find((p) => p.id === 'reddit_ads')!
|
|
1596
|
-
expect(r.isApplicable!({})).toBe(true)
|
|
1597
|
-
expect(r.isApplicable!({ query: 'sales automation' })).toBe(true)
|
|
1598
|
-
expect(r.isApplicable!({ industry: 'TECH_B2B' })).toBe(true)
|
|
1599
|
-
expect(r.isApplicable!({ domains: ['coldiq.com'] })).toBe(false)
|
|
1600
|
-
expect(r.isApplicable!({ advertiser_ids: ['AR123'] })).toBe(false)
|
|
1601
|
-
expect(r.isApplicable!({ search_urls: ['https://...'] })).toBe(false)
|
|
1602
|
-
expect(r.isApplicable!({ platform: 'google' })).toBe(false)
|
|
1603
|
-
expect(r.isApplicable!({ platform: 'reddit' })).toBe(true)
|
|
1604
|
-
})
|
|
1605
|
-
|
|
1606
1583
|
it('platform pin: platform=linkedin makes only linkedin_ad_library applicable (even without search_urls it fails the URL check)', () => {
|
|
1607
1584
|
const providers = getProviders('search_ads')
|
|
1608
1585
|
const pinned = { platform: 'linkedin', query: 'Salesforce' }
|
|
@@ -1667,30 +1644,7 @@ describe('registry', () => {
|
|
|
1667
1644
|
expect(capped.maxItems).toBe(100)
|
|
1668
1645
|
})
|
|
1669
1646
|
|
|
1670
|
-
it('
|
|
1671
|
-
const r = getProviders('search_ads').find((p) => p.id === 'reddit_ads')!
|
|
1672
|
-
const out = r.mapParams({
|
|
1673
|
-
query: 'sales automation',
|
|
1674
|
-
industry: 'TECH_B2B',
|
|
1675
|
-
budget_category: 'HIGH',
|
|
1676
|
-
post_type: 'VIDEO',
|
|
1677
|
-
objective_type: 'CONVERSIONS',
|
|
1678
|
-
}).body as Record<string, unknown>
|
|
1679
|
-
expect(out.keywords).toBe('sales automation')
|
|
1680
|
-
expect(out.industry).toBe('TECH_B2B')
|
|
1681
|
-
expect(out.budgetCategory).toBe('HIGH')
|
|
1682
|
-
expect(out.postType).toBe('VIDEO')
|
|
1683
|
-
expect(out.objectiveType).toBe('CONVERSIONS')
|
|
1684
|
-
})
|
|
1685
|
-
|
|
1686
|
-
it('reddit_ads.mapParams: query is optional (scrape-all)', () => {
|
|
1687
|
-
const r = getProviders('search_ads').find((p) => p.id === 'reddit_ads')!
|
|
1688
|
-
const out = r.mapParams({ industry: 'TECH_B2B' }).body as Record<string, unknown>
|
|
1689
|
-
expect(out.keywords).toBeUndefined()
|
|
1690
|
-
expect(out.industry).toBe('TECH_B2B')
|
|
1691
|
-
})
|
|
1692
|
-
|
|
1693
|
-
it('hasResult requires ads[] non-empty for all 5 providers', () => {
|
|
1647
|
+
it('hasResult requires ads[] non-empty for all 4 providers', () => {
|
|
1694
1648
|
const providers = getProviders('search_ads')
|
|
1695
1649
|
for (const p of providers) {
|
|
1696
1650
|
expect(p.hasResult({ ads: [{ id: '1' }] }), `${p.id} should return true`).toBe(true)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { listSkills, loadSkill, __resetSkillsCache } from '../src/skills.js'
|
|
3
|
+
|
|
4
|
+
const INDEX = {
|
|
5
|
+
$schema: 'https://schemas.agentskills.io/discovery/0.2.0/schema.json',
|
|
6
|
+
name: 'coldiq',
|
|
7
|
+
skills: [
|
|
8
|
+
{
|
|
9
|
+
name: 'contact-enrichment',
|
|
10
|
+
type: 'skill-md',
|
|
11
|
+
description: 'Enrich contacts with emails and phones.',
|
|
12
|
+
url: 'https://raw.example/skills/contact-enrichment/SKILL.md',
|
|
13
|
+
digest: 'sha256:abc',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'coldiq-search-enrich',
|
|
17
|
+
type: 'skill-md',
|
|
18
|
+
description: 'Search and enrich at scale.',
|
|
19
|
+
url: 'https://raw.example/skills/lima-data-api/SKILL.md',
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const BODY_WITH_RES =
|
|
25
|
+
'# Contact enrichment\n\nSee [resources/async-job-pattern.md](resources/async-job-pattern.md) and ' +
|
|
26
|
+
'[resources/credit-optimization.md](resources/credit-optimization.md).\n'
|
|
27
|
+
|
|
28
|
+
function mockFetch(map: Record<string, { status?: number; body: string }>) {
|
|
29
|
+
return vi.fn(async (url: string | URL | Request) => {
|
|
30
|
+
const u = String(url)
|
|
31
|
+
const hit = map[u]
|
|
32
|
+
if (!hit) return { ok: false, status: 404, text: async () => 'not found' } as Response
|
|
33
|
+
const status = hit.status ?? 200
|
|
34
|
+
return { ok: status >= 200 && status < 300, status, text: async () => hit.body } as Response
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const originalFetch = globalThis.fetch
|
|
39
|
+
|
|
40
|
+
describe('skills module', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
__resetSkillsCache()
|
|
43
|
+
delete process.env.COLDIQ_SKILLS_INDEX_URL
|
|
44
|
+
})
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
globalThis.fetch = originalFetch
|
|
47
|
+
vi.restoreAllMocks()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('listSkills returns name + description from the index', async () => {
|
|
51
|
+
globalThis.fetch = mockFetch({
|
|
52
|
+
'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
|
|
53
|
+
body: JSON.stringify(INDEX),
|
|
54
|
+
},
|
|
55
|
+
}) as typeof fetch
|
|
56
|
+
const skills = await listSkills()
|
|
57
|
+
expect(skills).toHaveLength(2)
|
|
58
|
+
expect(skills[0]).toEqual({ name: 'contact-enrichment', description: 'Enrich contacts with emails and phones.' })
|
|
59
|
+
expect(skills.map((s) => s.name)).toContain('coldiq-search-enrich')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('honors COLDIQ_SKILLS_INDEX_URL override', async () => {
|
|
63
|
+
process.env.COLDIQ_SKILLS_INDEX_URL = 'https://custom/index.json'
|
|
64
|
+
globalThis.fetch = mockFetch({ 'https://custom/index.json': { body: JSON.stringify(INDEX) } }) as typeof fetch
|
|
65
|
+
const skills = await listSkills()
|
|
66
|
+
expect(skills).toHaveLength(2)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('caches the index (one network call across two listSkills)', async () => {
|
|
70
|
+
const f = mockFetch({
|
|
71
|
+
'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
|
|
72
|
+
body: JSON.stringify(INDEX),
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
globalThis.fetch = f as typeof fetch
|
|
76
|
+
await listSkills()
|
|
77
|
+
await listSkills()
|
|
78
|
+
expect(f).toHaveBeenCalledTimes(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('loadSkill returns the body with referenced resources inlined', async () => {
|
|
82
|
+
globalThis.fetch = mockFetch({
|
|
83
|
+
'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
|
|
84
|
+
body: JSON.stringify(INDEX),
|
|
85
|
+
},
|
|
86
|
+
'https://raw.example/skills/contact-enrichment/SKILL.md': { body: BODY_WITH_RES },
|
|
87
|
+
'https://raw.example/skills/contact-enrichment/resources/async-job-pattern.md': { body: 'POLL LOOP DOC' },
|
|
88
|
+
'https://raw.example/skills/contact-enrichment/resources/credit-optimization.md': { body: 'CREDIT DOC' },
|
|
89
|
+
}) as typeof fetch
|
|
90
|
+
const doc = await loadSkill('contact-enrichment')
|
|
91
|
+
expect(doc).toContain('# Contact enrichment')
|
|
92
|
+
expect(doc).toContain('## Resource: resources/async-job-pattern.md')
|
|
93
|
+
expect(doc).toContain('POLL LOOP DOC')
|
|
94
|
+
expect(doc).toContain('## Resource: resources/credit-optimization.md')
|
|
95
|
+
expect(doc).toContain('CREDIT DOC')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('loadSkill tolerates a missing resource (skips it, still returns body)', async () => {
|
|
99
|
+
globalThis.fetch = mockFetch({
|
|
100
|
+
'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
|
|
101
|
+
body: JSON.stringify(INDEX),
|
|
102
|
+
},
|
|
103
|
+
'https://raw.example/skills/contact-enrichment/SKILL.md': { body: BODY_WITH_RES },
|
|
104
|
+
// resources intentionally 404
|
|
105
|
+
}) as typeof fetch
|
|
106
|
+
const doc = await loadSkill('contact-enrichment')
|
|
107
|
+
expect(doc).toContain('# Contact enrichment')
|
|
108
|
+
expect(doc).not.toContain('POLL LOOP DOC')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('loadSkill resolves folder≠name via the index url', async () => {
|
|
112
|
+
globalThis.fetch = mockFetch({
|
|
113
|
+
'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
|
|
114
|
+
body: JSON.stringify(INDEX),
|
|
115
|
+
},
|
|
116
|
+
'https://raw.example/skills/lima-data-api/SKILL.md': { body: '# Search & enrich\n' },
|
|
117
|
+
}) as typeof fetch
|
|
118
|
+
const doc = await loadSkill('coldiq-search-enrich')
|
|
119
|
+
expect(doc).toContain('# Search & enrich')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('loadSkill throws a helpful error for an unknown skill', async () => {
|
|
123
|
+
globalThis.fetch = mockFetch({
|
|
124
|
+
'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
|
|
125
|
+
body: JSON.stringify(INDEX),
|
|
126
|
+
},
|
|
127
|
+
}) as typeof fetch
|
|
128
|
+
await expect(loadSkill('does-not-exist')).rejects.toThrow(/unknown skill/i)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
listSkillsHandler,
|
|
4
|
+
listSkillsName,
|
|
5
|
+
listSkillsSchema,
|
|
6
|
+
} from '../../src/tools/list-skills.js'
|
|
7
|
+
import { __resetSkillsCache } from '../../src/skills.js'
|
|
8
|
+
|
|
9
|
+
const INDEX_URL =
|
|
10
|
+
'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json'
|
|
11
|
+
const originalFetch = globalThis.fetch
|
|
12
|
+
|
|
13
|
+
describe('list_skills handler', () => {
|
|
14
|
+
beforeEach(() => __resetSkillsCache())
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
globalThis.fetch = originalFetch
|
|
17
|
+
vi.restoreAllMocks()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('is a zero-arg tool', () => {
|
|
21
|
+
expect(listSkillsName).toBe('list_skills')
|
|
22
|
+
expect(listSkillsSchema).toEqual({})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns the catalog as { data: [...] }', async () => {
|
|
26
|
+
globalThis.fetch = vi.fn(async () => ({
|
|
27
|
+
ok: true,
|
|
28
|
+
status: 200,
|
|
29
|
+
text: async () =>
|
|
30
|
+
JSON.stringify({ skills: [{ name: 'tam-scoring', description: 'Score a TAM.', url: 'https://x/SKILL.md' }] }),
|
|
31
|
+
})) as unknown as typeof fetch
|
|
32
|
+
const res = await listSkillsHandler({})
|
|
33
|
+
expect(res.isError).toBeFalsy()
|
|
34
|
+
const parsed = JSON.parse(res.content[0].text)
|
|
35
|
+
expect(parsed.data).toEqual([{ name: 'tam-scoring', description: 'Score a TAM.' }])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns an error result (not a throw) when the index is unreachable', async () => {
|
|
39
|
+
globalThis.fetch = vi.fn(async () => ({ ok: false, status: 500, text: async () => 'err' })) as unknown as typeof fetch
|
|
40
|
+
const res = await listSkillsHandler({})
|
|
41
|
+
expect(res.isError).toBe(true)
|
|
42
|
+
expect(JSON.parse(res.content[0].text).error).toMatch(/could not load/i)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { loadSkillHandler, loadSkillName, loadSkillSchema } from '../../src/tools/load-skill.js'
|
|
3
|
+
import { __resetSkillsCache } from '../../src/skills.js'
|
|
4
|
+
|
|
5
|
+
const INDEX_URL =
|
|
6
|
+
'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json'
|
|
7
|
+
|
|
8
|
+
function mockFetch(map: Record<string, string>) {
|
|
9
|
+
return vi.fn(async (url: string | URL | Request) => {
|
|
10
|
+
const body = map[String(url)]
|
|
11
|
+
return { ok: body !== undefined, status: body !== undefined ? 200 : 404, text: async () => body ?? 'nf' } as Response
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
const originalFetch = globalThis.fetch
|
|
15
|
+
|
|
16
|
+
describe('load_skill handler', () => {
|
|
17
|
+
beforeEach(() => __resetSkillsCache())
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
globalThis.fetch = originalFetch
|
|
20
|
+
vi.restoreAllMocks()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('declares a required name arg', () => {
|
|
24
|
+
expect(loadSkillName).toBe('load_skill')
|
|
25
|
+
expect(Object.keys(loadSkillSchema)).toContain('name')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns the skill body as text', async () => {
|
|
29
|
+
globalThis.fetch = mockFetch({
|
|
30
|
+
[INDEX_URL]: JSON.stringify({ skills: [{ name: 'apollo-search', description: 'd', url: 'https://x/skills/apollo-search/SKILL.md' }] }),
|
|
31
|
+
'https://x/skills/apollo-search/SKILL.md': '# Apollo search\nstep 1',
|
|
32
|
+
}) as typeof fetch
|
|
33
|
+
const res = await loadSkillHandler({ name: 'apollo-search' })
|
|
34
|
+
expect(res.isError).toBeFalsy()
|
|
35
|
+
expect(res.content[0].text).toContain('# Apollo search')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('errors (not throws) on a missing name', async () => {
|
|
39
|
+
const res = await loadSkillHandler({})
|
|
40
|
+
expect(res.isError).toBe(true)
|
|
41
|
+
expect(res.content[0].text).toMatch(/list_skills/)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('errors with the available list on an unknown skill', async () => {
|
|
45
|
+
globalThis.fetch = mockFetch({
|
|
46
|
+
[INDEX_URL]: JSON.stringify({ skills: [{ name: 'apollo-search', description: 'd', url: 'https://x/SKILL.md' }] }),
|
|
47
|
+
}) as typeof fetch
|
|
48
|
+
const res = await loadSkillHandler({ name: 'nope' })
|
|
49
|
+
expect(res.isError).toBe(true)
|
|
50
|
+
expect(res.content[0].text).toMatch(/unknown skill/i)
|
|
51
|
+
expect(res.content[0].text).toMatch(/apollo-search/)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -16,7 +16,7 @@ describe('providerDisplayName', () => {
|
|
|
16
16
|
})
|
|
17
17
|
|
|
18
18
|
it('brands Apify-backed scrapers as their data source, never raw slug', () => {
|
|
19
|
-
expect(providerDisplayName('
|
|
19
|
+
expect(providerDisplayName('meta_ads')).toBe('Meta Ads')
|
|
20
20
|
expect(providerDisplayName('linkedin_ad_library')).toBe('LinkedIn Ad Library')
|
|
21
21
|
expect(providerDisplayName('google_maps')).toBe('Google Maps')
|
|
22
22
|
})
|
|
@@ -28,7 +28,7 @@ describe('providerDisplayName', () => {
|
|
|
28
28
|
|
|
29
29
|
it('never returns a raw slug or the word "Apify"', () => {
|
|
30
30
|
const ids = [
|
|
31
|
-
'limadata-work-email', '
|
|
31
|
+
'limadata-work-email', 'twitter_ads', 'fullenrich', 'ai-ark-people',
|
|
32
32
|
'linkupapi-validate', 'theirstack-buying-intents', 'completely_unknown_slug',
|
|
33
33
|
]
|
|
34
34
|
for (const id of ids) {
|