@stage-labs/metro 0.1.0-beta.326 → 0.1.0-beta.327
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/package.json +2 -2
- package/runtime/node_modules/@metro-labs/core/package.json +3 -1
- package/runtime/node_modules/@metro-labs/core/src/brands.ts +100 -0
- package/runtime/node_modules/@metro-labs/core/src/favicon.ts +38 -0
- package/runtime/node_modules/@metro-labs/core/src/skill-source.ts +5 -5
- package/runtime/node_modules/@metro-labs/daemon/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/github-fetch.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +2 -2
- package/runtime/node_modules/@metro-labs/discord-bot/package.json +1 -1
- package/runtime/node_modules/@metro-labs/gmail/package.json +1 -1
- package/runtime/node_modules/@metro-labs/http/package.json +1 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-oauth.ts +1 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +42 -2
- package/runtime/node_modules/@metro-labs/inference/package.json +1 -1
- package/runtime/node_modules/@metro-labs/outlook/package.json +1 -1
- package/runtime/node_modules/@metro-labs/telegram/package.json +1 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/package.json +1 -1
- package/runtime/node_modules/@metro-labs/threema/package.json +1 -1
- package/runtime/node_modules/@metro-labs/webhook/package.json +1 -1
- package/runtime/node_modules/@metro-labs/whatsapp/package.json +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/package.json +1 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +3 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/brands.ts +100 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/favicon.ts +38 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/skill-source.ts +5 -5
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.327",
|
|
4
4
|
"description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"test": "tsc --noEmit && node scripts/stage-runtime.mjs && bun test test/"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@stage-labs/config": "0.1.0-beta.
|
|
35
|
+
"@stage-labs/config": "0.1.0-beta.5",
|
|
36
36
|
"eslint": "^10.3.0",
|
|
37
37
|
"knip": "^6.15.0",
|
|
38
38
|
"madge": "^8.0.0",
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"./log": "./src/log.ts",
|
|
9
9
|
"./events": "./src/events.ts",
|
|
10
10
|
"./is-record": "./src/is-record.ts",
|
|
11
|
+
"./favicon": "./src/favicon.ts",
|
|
12
|
+
"./brands": "./src/brands.ts",
|
|
11
13
|
"./runner-activity": "./src/runner-activity.ts",
|
|
12
14
|
"./automation-store": "./src/automation-store.ts",
|
|
13
15
|
"./automation-types": "./src/automation-types.ts",
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
"pino-pretty": "^13.1.3"
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
55
|
-
"@stage-labs/config": "0.1.0-beta.
|
|
57
|
+
"@stage-labs/config": "0.1.0-beta.5",
|
|
56
58
|
"eslint": "^10.3.0",
|
|
57
59
|
"knip": "^6.15.0",
|
|
58
60
|
"madge": "^8.0.0",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { baseDomain } from './favicon.js';
|
|
2
|
+
|
|
3
|
+
interface BrandLogo {
|
|
4
|
+
light: string;
|
|
5
|
+
dark?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const BY_HOST: Record<string, string> = {
|
|
9
|
+
'gemini.google.com': 'gemini',
|
|
10
|
+
'aws.amazon.com': 'aws',
|
|
11
|
+
'mail.google.com': 'gmail-0a242131',
|
|
12
|
+
'gmailmcp.googleapis.com': 'gmail-0a242131',
|
|
13
|
+
'drive.google.com': 'googledrive',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const BY_DOMAIN: Record<string, string> = {
|
|
17
|
+
'xmtp.org': 'xmtp',
|
|
18
|
+
'whatsapp.com': 'whatsapp',
|
|
19
|
+
'whatsapp.net': 'whatsapp',
|
|
20
|
+
'telegram.org': 'telegram',
|
|
21
|
+
't.me': 'telegram',
|
|
22
|
+
'discord.com': 'discord',
|
|
23
|
+
'discord.gg': 'discord',
|
|
24
|
+
'threema.ch': 'threema',
|
|
25
|
+
'outlook.com': 'outlook',
|
|
26
|
+
'office.com': 'outlook',
|
|
27
|
+
'live.com': 'outlook',
|
|
28
|
+
'microsoft.com': 'microsoft',
|
|
29
|
+
'sharepoint.com': 'sharepoint',
|
|
30
|
+
'linear.app': 'linear',
|
|
31
|
+
'notion.so': 'notion',
|
|
32
|
+
'notion.com': 'notion',
|
|
33
|
+
'claude.ai': 'claude',
|
|
34
|
+
'anthropic.com': 'claude',
|
|
35
|
+
'slack.com': 'slack',
|
|
36
|
+
'github.com': 'github',
|
|
37
|
+
'openrouter.ai': 'openrouter',
|
|
38
|
+
'routeway.ai': 'routeway',
|
|
39
|
+
'openai.com': 'openai',
|
|
40
|
+
'mistral.ai': 'mistral',
|
|
41
|
+
'qwen.ai': 'qwen',
|
|
42
|
+
'qwenlm.ai': 'qwen',
|
|
43
|
+
'chatgpt.com': 'openai',
|
|
44
|
+
'x.ai': 'grok',
|
|
45
|
+
'grok.com': 'grok',
|
|
46
|
+
'atlassian.com': 'atlassian',
|
|
47
|
+
'atlassian.net': 'atlassian',
|
|
48
|
+
'hubspot.com': 'hubspot',
|
|
49
|
+
'stripe.com': 'stripe',
|
|
50
|
+
'sentry.io': 'sentry',
|
|
51
|
+
'asana.com': 'asana',
|
|
52
|
+
'intercom.com': 'intercom',
|
|
53
|
+
'intercom.io': 'intercom',
|
|
54
|
+
'cloudflare.com': 'cloudflare',
|
|
55
|
+
'figma.com': 'figma',
|
|
56
|
+
'box.com': 'box',
|
|
57
|
+
'zapier.com': 'zapier',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const WITH_DARK = new Set(['github', 'openrouter', 'aws', 'openai', 'grok']);
|
|
61
|
+
const RASTER = new Set(['routeway']);
|
|
62
|
+
|
|
63
|
+
function logoOf(slug: string): BrandLogo {
|
|
64
|
+
const light = `/brands/${slug}.${RASTER.has(slug) ? 'png' : 'svg'}`;
|
|
65
|
+
return WITH_DARK.has(slug) ? { light, dark: `/brands/${slug}-dark.svg` } : { light };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function brandLogo(url: string): BrandLogo | null {
|
|
69
|
+
let host: string;
|
|
70
|
+
try {
|
|
71
|
+
host = new URL(url).hostname.toLowerCase();
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const slug = BY_HOST[host] ?? BY_DOMAIN[baseDomain(host)];
|
|
76
|
+
return slug === undefined ? null : logoOf(slug);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function brandSrc(url: string, dark: boolean): string | null {
|
|
80
|
+
const logo = brandLogo(url);
|
|
81
|
+
if (logo === null) return null;
|
|
82
|
+
return dark ? (logo.dark ?? logo.light) : logo.light;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ConnectorIcon {
|
|
86
|
+
src: string;
|
|
87
|
+
mimeType?: string;
|
|
88
|
+
sizes?: string[];
|
|
89
|
+
theme?: 'light' | 'dark';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const BRAND_ORIGIN = 'https://metro.box';
|
|
93
|
+
|
|
94
|
+
const brandIcon = (path: string, theme?: 'light' | 'dark'): ConnectorIcon => ({ src: `${BRAND_ORIGIN}${path}`, mimeType: path.endsWith('.png') ? 'image/png' : 'image/svg+xml', ...(theme === undefined ? {} : { theme }) });
|
|
95
|
+
|
|
96
|
+
export function brandIcons(url: string): ConnectorIcon[] {
|
|
97
|
+
const logo = brandLogo(url);
|
|
98
|
+
if (logo === null) return [];
|
|
99
|
+
return logo.dark === undefined ? [brandIcon(logo.light)] : [brandIcon(logo.light, 'light'), brandIcon(logo.dark, 'dark')];
|
|
100
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const CHECKED_BASE = 'https://t1.gstatic.com/faviconV2';
|
|
2
|
+
|
|
3
|
+
const MULTI_LABEL_SUFFIXES = new Set([
|
|
4
|
+
'ac.uk', 'co.uk', 'gov.uk', 'me.uk', 'net.uk', 'org.uk',
|
|
5
|
+
'com.au', 'edu.au', 'net.au', 'org.au',
|
|
6
|
+
'co.nz', 'net.nz', 'org.nz',
|
|
7
|
+
'ac.jp', 'co.jp', 'ne.jp', 'or.jp',
|
|
8
|
+
'co.kr', 'co.za', 'co.il', 'co.in', 'net.in', 'org.in',
|
|
9
|
+
'com.ar', 'com.br', 'net.br', 'org.br',
|
|
10
|
+
'com.cn', 'net.cn', 'org.cn',
|
|
11
|
+
'com.hk', 'com.mx', 'com.pl', 'com.sg', 'com.tr', 'com.tw',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export function baseDomain(host: string): string {
|
|
15
|
+
const labels = host
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/\.$/, '')
|
|
18
|
+
.split('.')
|
|
19
|
+
.filter((label) => label !== '');
|
|
20
|
+
if (labels.length <= 2) return labels.join('.');
|
|
21
|
+
const lastTwo = labels.slice(-2).join('.');
|
|
22
|
+
if (MULTI_LABEL_SUFFIXES.has(lastTwo)) return labels.slice(-3).join('.');
|
|
23
|
+
return lastTwo;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function siteDomain(url: string): string {
|
|
27
|
+
try {
|
|
28
|
+
const domain = baseDomain(new URL(url).hostname);
|
|
29
|
+
return domain.includes('.') ? domain : '';
|
|
30
|
+
} catch {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function checkedFaviconUrl(url: string, size: number): string {
|
|
36
|
+
const domain = siteDomain(url);
|
|
37
|
+
return domain === '' ? '' : `${CHECKED_BASE}?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(`https://${domain}`)}&size=${String(size)}`;
|
|
38
|
+
}
|
|
@@ -4,10 +4,10 @@ import { isRecord } from './is-record.js';
|
|
|
4
4
|
export const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
5
5
|
export const SKILL_HASH = /^[a-f0-9]{64}$/;
|
|
6
6
|
export const SKILL_COMMIT = /^[a-f0-9]{40}$/;
|
|
7
|
-
export const SKILL_FILE_MAX =
|
|
8
|
-
export const SKILL_TOTAL_MAX =
|
|
9
|
-
export const SKILL_FILES_MAX =
|
|
10
|
-
export const SKILL_MANIFEST_MAX =
|
|
7
|
+
export const SKILL_FILE_MAX = 512 * 1024;
|
|
8
|
+
export const SKILL_TOTAL_MAX = 32 * 1024 * 1024;
|
|
9
|
+
export const SKILL_FILES_MAX = 512;
|
|
10
|
+
export const SKILL_MANIFEST_MAX = 256 * 1024;
|
|
11
11
|
|
|
12
12
|
export interface SkillSource {
|
|
13
13
|
repository: string;
|
|
@@ -48,7 +48,7 @@ export const skillRelease = (root: string, id: string): string => {
|
|
|
48
48
|
export function skillPath(path: string): boolean {
|
|
49
49
|
const parts = path.split('/');
|
|
50
50
|
return path.length <= 240 && parts.length >= 2 && parts.length <= 8 && SKILL_NAME.test(parts[0] ?? '') &&
|
|
51
|
-
parts.every((part) => /^[A-Za-z0-
|
|
51
|
+
parts.every((part) => /^[A-Za-z0-9_](?:[A-Za-z0-9 ._()-]{0,126}[A-Za-z0-9_.()-])?$/.test(part)) &&
|
|
52
52
|
!parts.slice(1, -1).includes('SKILL.md') && !(parts.length > 2 && parts.at(-1) === 'SKILL.md');
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -142,7 +142,7 @@ function immutableCommit(data: unknown): { sha: string; tree: string } {
|
|
|
142
142
|
|
|
143
143
|
function regularFile(entry: Entry): void {
|
|
144
144
|
if (entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode)) throw new GitHubFailure('Symlinks, submodules and special files are not supported in skills.');
|
|
145
|
-
if (!skillPath(entry.path) || entry.size < 0 || entry.size > SKILL_FILE_MAX || !Number.isInteger(entry.size)) throw new GitHubFailure(
|
|
145
|
+
if (!skillPath(entry.path) || entry.size < 0 || entry.size > SKILL_FILE_MAX || !Number.isInteger(entry.size)) throw new GitHubFailure(`A skills path or file size is not supported. Use skill-name/SKILL.md and regular resources, up to ${SKILL_FILE_MAX / 1024} KiB each.`);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
function checkedEntries(entries: Entry[]): Entry[] {
|
|
@@ -154,7 +154,7 @@ function checkedEntries(entries: Entry[]): Entry[] {
|
|
|
154
154
|
total += entry.size;
|
|
155
155
|
files.push(entry);
|
|
156
156
|
}
|
|
157
|
-
if (files.length > SKILL_FILES_MAX || total > SKILL_TOTAL_MAX) throw new GitHubFailure(
|
|
157
|
+
if (files.length > SKILL_FILES_MAX || total > SKILL_TOTAL_MAX) throw new GitHubFailure(`Skills exceed ${SKILL_FILES_MAX} files or ${SKILL_TOTAL_MAX / 1024 / 1024} MiB.`);
|
|
158
158
|
if (new Set(files.map((entry) => entry.path.toLowerCase())).size !== files.length) throw new GitHubFailure('Skills contain duplicate file paths.');
|
|
159
159
|
return files;
|
|
160
160
|
}
|
|
@@ -4,7 +4,7 @@ import { Readable } from 'node:stream';
|
|
|
4
4
|
import { log } from '@metro-labs/core/log';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
6
|
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
7
|
-
import { SKILL_HASH, SKILL_MANIFEST_MAX, skillGeneration, skillRelease, skillSourceRoot, type SkillGeneration } from '@metro-labs/core/skill-source';
|
|
7
|
+
import { SKILL_FILE_MAX, SKILL_HASH, SKILL_MANIFEST_MAX, skillGeneration, skillRelease, skillSourceRoot, type SkillGeneration } from '@metro-labs/core/skill-source';
|
|
8
8
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
9
9
|
import { readFileSync, statSync } from '../agent-user/agent-fs.js';
|
|
10
10
|
import { receiveHomeFile, writeHomeText } from '../agent-user/home-fs.js';
|
|
@@ -135,7 +135,7 @@ export class GitHubSkills {
|
|
|
135
135
|
read(id: string): ClaudeSkill & { text: string } {
|
|
136
136
|
const row = this.rows().find((skill) => skill.id === id);
|
|
137
137
|
if (row === undefined) throw new ApiError('no such skill', 404);
|
|
138
|
-
if (statSync(row.path).size >
|
|
138
|
+
if (statSync(row.path).size > SKILL_FILE_MAX) throw new ApiError('The managed skill changed on disk. Sync it again.', 409);
|
|
139
139
|
return { ...row, text: readFileSync(row.path, 'utf8') };
|
|
140
140
|
}
|
|
141
141
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConnectorIcon } from '@metro-labs/core/brands';
|
|
1
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
3
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
4
|
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
@@ -14,7 +15,16 @@ export interface RemoteTool {
|
|
|
14
15
|
inputSchema?: Record<string, unknown>;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
export interface RemoteServer {
|
|
19
|
+
tools: RemoteTool[];
|
|
20
|
+
icons: ConnectorIcon[];
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
const TIMEOUT_MS = 15_000;
|
|
24
|
+
const ICONS_MAX = 4;
|
|
25
|
+
const ICON_URL_MAX = 2048;
|
|
26
|
+
const MIME_RE = /^image\/[a-z0-9.+-]{1,64}$/i;
|
|
27
|
+
const SIZE_RE = /^(any|\d{1,4}x\d{1,4})$/;
|
|
18
28
|
const MAX_PAGES = 100;
|
|
19
29
|
const MAX_TOOLS = 2000;
|
|
20
30
|
|
|
@@ -86,14 +96,44 @@ async function walkTools(url: URL, auth: ConnectorAuth, session: Map<string, str
|
|
|
86
96
|
throw refused('The provider did not return a complete tool list.');
|
|
87
97
|
}
|
|
88
98
|
|
|
89
|
-
|
|
99
|
+
function iconSrc(raw: unknown): string | null {
|
|
100
|
+
if (typeof raw !== 'string' || raw.length > ICON_URL_MAX) return null;
|
|
101
|
+
try {
|
|
102
|
+
const src = new URL(raw);
|
|
103
|
+
return src.protocol === 'https:' && src.username === '' && src.password === '' ? src.toString() : null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function iconOf(raw: unknown): ConnectorIcon | null {
|
|
110
|
+
const src = isRecord(raw) ? iconSrc(raw.src) : null;
|
|
111
|
+
if (!isRecord(raw) || src === null) return null;
|
|
112
|
+
const sizes = Array.isArray(raw.sizes) ? raw.sizes.filter((size): size is string => typeof size === 'string' && SIZE_RE.test(size)).slice(0, ICONS_MAX) : [];
|
|
113
|
+
return {
|
|
114
|
+
src,
|
|
115
|
+
...(typeof raw.mimeType === 'string' && MIME_RE.test(raw.mimeType) ? { mimeType: raw.mimeType } : {}),
|
|
116
|
+
...(sizes.length > 0 ? { sizes } : {}),
|
|
117
|
+
...(raw.theme === 'light' || raw.theme === 'dark' ? { theme: raw.theme } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function serverIconsOf(init: Record<string, unknown>): ConnectorIcon[] {
|
|
122
|
+
const info = isRecord(init.serverInfo) ? init.serverInfo : {};
|
|
123
|
+
if (!Array.isArray(info.icons)) return [];
|
|
124
|
+
return info.icons.map(iconOf).filter((icon): icon is ConnectorIcon => icon !== null).slice(0, ICONS_MAX);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const listRemoteTools = async (url: URL, auth: ConnectorAuth, fetchImpl?: FetchLike): Promise<RemoteTool[]> => (await listRemoteServer(url, auth, fetchImpl)).tools;
|
|
128
|
+
|
|
129
|
+
export async function listRemoteServer(url: URL, auth: ConnectorAuth, fetchImpl?: FetchLike): Promise<RemoteServer> {
|
|
90
130
|
const session = new Map<string, string>();
|
|
91
131
|
try {
|
|
92
132
|
const init = await rpc(url, auth, session, INITIALIZE, fetchImpl);
|
|
93
133
|
if (init === null || typeof init.protocolVersion !== 'string') throw refused(`${url.hostname} answered, but it does not speak MCP.`);
|
|
94
134
|
session.set('mcp-protocol-version', init.protocolVersion);
|
|
95
135
|
await rpc(url, auth, session, { jsonrpc: '2.0', method: 'notifications/initialized' }, fetchImpl);
|
|
96
|
-
return await walkTools(url, auth, session, fetchImpl);
|
|
136
|
+
return { tools: await walkTools(url, auth, session, fetchImpl), icons: serverIconsOf(init) };
|
|
97
137
|
} finally {
|
|
98
138
|
await endSession(url, auth, session, fetchImpl);
|
|
99
139
|
}
|
package/runtime/runtime.json
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"./log": "./src/log.ts",
|
|
9
9
|
"./events": "./src/events.ts",
|
|
10
10
|
"./is-record": "./src/is-record.ts",
|
|
11
|
+
"./favicon": "./src/favicon.ts",
|
|
12
|
+
"./brands": "./src/brands.ts",
|
|
11
13
|
"./runner-activity": "./src/runner-activity.ts",
|
|
12
14
|
"./automation-store": "./src/automation-store.ts",
|
|
13
15
|
"./automation-types": "./src/automation-types.ts",
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
"pino-pretty": "^13.1.3"
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
55
|
-
"@stage-labs/config": "0.1.0-beta.
|
|
57
|
+
"@stage-labs/config": "0.1.0-beta.5",
|
|
56
58
|
"eslint": "^10.3.0",
|
|
57
59
|
"knip": "^6.15.0",
|
|
58
60
|
"madge": "^8.0.0",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { baseDomain } from './favicon.js';
|
|
2
|
+
|
|
3
|
+
interface BrandLogo {
|
|
4
|
+
light: string;
|
|
5
|
+
dark?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const BY_HOST: Record<string, string> = {
|
|
9
|
+
'gemini.google.com': 'gemini',
|
|
10
|
+
'aws.amazon.com': 'aws',
|
|
11
|
+
'mail.google.com': 'gmail-0a242131',
|
|
12
|
+
'gmailmcp.googleapis.com': 'gmail-0a242131',
|
|
13
|
+
'drive.google.com': 'googledrive',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const BY_DOMAIN: Record<string, string> = {
|
|
17
|
+
'xmtp.org': 'xmtp',
|
|
18
|
+
'whatsapp.com': 'whatsapp',
|
|
19
|
+
'whatsapp.net': 'whatsapp',
|
|
20
|
+
'telegram.org': 'telegram',
|
|
21
|
+
't.me': 'telegram',
|
|
22
|
+
'discord.com': 'discord',
|
|
23
|
+
'discord.gg': 'discord',
|
|
24
|
+
'threema.ch': 'threema',
|
|
25
|
+
'outlook.com': 'outlook',
|
|
26
|
+
'office.com': 'outlook',
|
|
27
|
+
'live.com': 'outlook',
|
|
28
|
+
'microsoft.com': 'microsoft',
|
|
29
|
+
'sharepoint.com': 'sharepoint',
|
|
30
|
+
'linear.app': 'linear',
|
|
31
|
+
'notion.so': 'notion',
|
|
32
|
+
'notion.com': 'notion',
|
|
33
|
+
'claude.ai': 'claude',
|
|
34
|
+
'anthropic.com': 'claude',
|
|
35
|
+
'slack.com': 'slack',
|
|
36
|
+
'github.com': 'github',
|
|
37
|
+
'openrouter.ai': 'openrouter',
|
|
38
|
+
'routeway.ai': 'routeway',
|
|
39
|
+
'openai.com': 'openai',
|
|
40
|
+
'mistral.ai': 'mistral',
|
|
41
|
+
'qwen.ai': 'qwen',
|
|
42
|
+
'qwenlm.ai': 'qwen',
|
|
43
|
+
'chatgpt.com': 'openai',
|
|
44
|
+
'x.ai': 'grok',
|
|
45
|
+
'grok.com': 'grok',
|
|
46
|
+
'atlassian.com': 'atlassian',
|
|
47
|
+
'atlassian.net': 'atlassian',
|
|
48
|
+
'hubspot.com': 'hubspot',
|
|
49
|
+
'stripe.com': 'stripe',
|
|
50
|
+
'sentry.io': 'sentry',
|
|
51
|
+
'asana.com': 'asana',
|
|
52
|
+
'intercom.com': 'intercom',
|
|
53
|
+
'intercom.io': 'intercom',
|
|
54
|
+
'cloudflare.com': 'cloudflare',
|
|
55
|
+
'figma.com': 'figma',
|
|
56
|
+
'box.com': 'box',
|
|
57
|
+
'zapier.com': 'zapier',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const WITH_DARK = new Set(['github', 'openrouter', 'aws', 'openai', 'grok']);
|
|
61
|
+
const RASTER = new Set(['routeway']);
|
|
62
|
+
|
|
63
|
+
function logoOf(slug: string): BrandLogo {
|
|
64
|
+
const light = `/brands/${slug}.${RASTER.has(slug) ? 'png' : 'svg'}`;
|
|
65
|
+
return WITH_DARK.has(slug) ? { light, dark: `/brands/${slug}-dark.svg` } : { light };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function brandLogo(url: string): BrandLogo | null {
|
|
69
|
+
let host: string;
|
|
70
|
+
try {
|
|
71
|
+
host = new URL(url).hostname.toLowerCase();
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const slug = BY_HOST[host] ?? BY_DOMAIN[baseDomain(host)];
|
|
76
|
+
return slug === undefined ? null : logoOf(slug);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function brandSrc(url: string, dark: boolean): string | null {
|
|
80
|
+
const logo = brandLogo(url);
|
|
81
|
+
if (logo === null) return null;
|
|
82
|
+
return dark ? (logo.dark ?? logo.light) : logo.light;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ConnectorIcon {
|
|
86
|
+
src: string;
|
|
87
|
+
mimeType?: string;
|
|
88
|
+
sizes?: string[];
|
|
89
|
+
theme?: 'light' | 'dark';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const BRAND_ORIGIN = 'https://metro.box';
|
|
93
|
+
|
|
94
|
+
const brandIcon = (path: string, theme?: 'light' | 'dark'): ConnectorIcon => ({ src: `${BRAND_ORIGIN}${path}`, mimeType: path.endsWith('.png') ? 'image/png' : 'image/svg+xml', ...(theme === undefined ? {} : { theme }) });
|
|
95
|
+
|
|
96
|
+
export function brandIcons(url: string): ConnectorIcon[] {
|
|
97
|
+
const logo = brandLogo(url);
|
|
98
|
+
if (logo === null) return [];
|
|
99
|
+
return logo.dark === undefined ? [brandIcon(logo.light)] : [brandIcon(logo.light, 'light'), brandIcon(logo.dark, 'dark')];
|
|
100
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const CHECKED_BASE = 'https://t1.gstatic.com/faviconV2';
|
|
2
|
+
|
|
3
|
+
const MULTI_LABEL_SUFFIXES = new Set([
|
|
4
|
+
'ac.uk', 'co.uk', 'gov.uk', 'me.uk', 'net.uk', 'org.uk',
|
|
5
|
+
'com.au', 'edu.au', 'net.au', 'org.au',
|
|
6
|
+
'co.nz', 'net.nz', 'org.nz',
|
|
7
|
+
'ac.jp', 'co.jp', 'ne.jp', 'or.jp',
|
|
8
|
+
'co.kr', 'co.za', 'co.il', 'co.in', 'net.in', 'org.in',
|
|
9
|
+
'com.ar', 'com.br', 'net.br', 'org.br',
|
|
10
|
+
'com.cn', 'net.cn', 'org.cn',
|
|
11
|
+
'com.hk', 'com.mx', 'com.pl', 'com.sg', 'com.tr', 'com.tw',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export function baseDomain(host: string): string {
|
|
15
|
+
const labels = host
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/\.$/, '')
|
|
18
|
+
.split('.')
|
|
19
|
+
.filter((label) => label !== '');
|
|
20
|
+
if (labels.length <= 2) return labels.join('.');
|
|
21
|
+
const lastTwo = labels.slice(-2).join('.');
|
|
22
|
+
if (MULTI_LABEL_SUFFIXES.has(lastTwo)) return labels.slice(-3).join('.');
|
|
23
|
+
return lastTwo;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function siteDomain(url: string): string {
|
|
27
|
+
try {
|
|
28
|
+
const domain = baseDomain(new URL(url).hostname);
|
|
29
|
+
return domain.includes('.') ? domain : '';
|
|
30
|
+
} catch {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function checkedFaviconUrl(url: string, size: number): string {
|
|
36
|
+
const domain = siteDomain(url);
|
|
37
|
+
return domain === '' ? '' : `${CHECKED_BASE}?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(`https://${domain}`)}&size=${String(size)}`;
|
|
38
|
+
}
|
|
@@ -4,10 +4,10 @@ import { isRecord } from './is-record.js';
|
|
|
4
4
|
export const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
5
5
|
export const SKILL_HASH = /^[a-f0-9]{64}$/;
|
|
6
6
|
export const SKILL_COMMIT = /^[a-f0-9]{40}$/;
|
|
7
|
-
export const SKILL_FILE_MAX =
|
|
8
|
-
export const SKILL_TOTAL_MAX =
|
|
9
|
-
export const SKILL_FILES_MAX =
|
|
10
|
-
export const SKILL_MANIFEST_MAX =
|
|
7
|
+
export const SKILL_FILE_MAX = 512 * 1024;
|
|
8
|
+
export const SKILL_TOTAL_MAX = 32 * 1024 * 1024;
|
|
9
|
+
export const SKILL_FILES_MAX = 512;
|
|
10
|
+
export const SKILL_MANIFEST_MAX = 256 * 1024;
|
|
11
11
|
|
|
12
12
|
export interface SkillSource {
|
|
13
13
|
repository: string;
|
|
@@ -48,7 +48,7 @@ export const skillRelease = (root: string, id: string): string => {
|
|
|
48
48
|
export function skillPath(path: string): boolean {
|
|
49
49
|
const parts = path.split('/');
|
|
50
50
|
return path.length <= 240 && parts.length >= 2 && parts.length <= 8 && SKILL_NAME.test(parts[0] ?? '') &&
|
|
51
|
-
parts.every((part) => /^[A-Za-z0-
|
|
51
|
+
parts.every((part) => /^[A-Za-z0-9_](?:[A-Za-z0-9 ._()-]{0,126}[A-Za-z0-9_.()-])?$/.test(part)) &&
|
|
52
52
|
!parts.slice(1, -1).includes('SKILL.md') && !(parts.length > 2 && parts.at(-1) === 'SKILL.md');
|
|
53
53
|
}
|
|
54
54
|
|