Skip to content

Commit cbf3122

Browse files
committed
init (#383)
1 parent 8d6bb57 commit cbf3122

File tree

2 files changed

+169
-1
lines changed

2 files changed

+169
-1
lines changed

src/add.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ import {
3939
getNonUniversalAgents,
4040
isUniversalAgent,
4141
} from './agents.ts';
42-
import { track, setVersion } from './telemetry.ts';
42+
import {
43+
track,
44+
setVersion,
45+
fetchAuditData,
46+
type AuditResponse,
47+
type SkillAuditData,
48+
type PartnerAudit,
49+
} from './telemetry.ts';
4350
import { findProvider, wellKnownProvider, type WellKnownSkill } from './providers/index.ts';
4451
import { fetchMintlifySkill } from './mintlify.ts';
4552
import {
@@ -56,6 +63,91 @@ export function initTelemetry(version: string): void {
5663
setVersion(version);
5764
}
5865

66+
// ─── Security Advisory ───
67+
68+
function riskLabel(risk: string): string {
69+
switch (risk) {
70+
case 'critical':
71+
return pc.red(pc.bold('Critical Risk'));
72+
case 'high':
73+
return pc.red('High Risk');
74+
case 'medium':
75+
return pc.yellow('Med Risk');
76+
case 'low':
77+
return pc.green('Low Risk');
78+
case 'safe':
79+
return pc.green('Safe');
80+
default:
81+
return pc.dim('--');
82+
}
83+
}
84+
85+
function socketLabel(audit: PartnerAudit | undefined): string {
86+
if (!audit) return pc.dim('--');
87+
const count = audit.alerts ?? 0;
88+
return count > 0 ? pc.red(`${count} alert${count !== 1 ? 's' : ''}`) : pc.green('0 alerts');
89+
}
90+
91+
/** Pad a string to a given visible width (ignoring ANSI escape codes). */
92+
function padEnd(str: string, width: number): string {
93+
// Strip ANSI codes to measure visible length
94+
const visible = str.replace(/\x1b\[[0-9;]*m/g, '');
95+
const pad = Math.max(0, width - visible.length);
96+
return str + ' '.repeat(pad);
97+
}
98+
99+
/**
100+
* Render a compact security table showing partner audit results.
101+
* Returns the lines to display, or empty array if no data.
102+
*/
103+
function buildSecurityLines(
104+
auditData: AuditResponse | null,
105+
skills: Array<{ slug: string; displayName: string }>,
106+
source: string
107+
): string[] {
108+
if (!auditData) return [];
109+
110+
// Check if we have any audit data at all
111+
const hasAny = skills.some((s) => {
112+
const data = auditData[s.slug];
113+
return data && Object.keys(data).length > 0;
114+
});
115+
if (!hasAny) return [];
116+
117+
// Compute column width for skill names
118+
const nameWidth = Math.min(Math.max(...skills.map((s) => s.displayName.length)), 36);
119+
120+
// Header
121+
const lines: string[] = [];
122+
const header =
123+
padEnd('', nameWidth + 2) +
124+
padEnd(pc.dim('Gen'), 18) +
125+
padEnd(pc.dim('Socket'), 18) +
126+
pc.dim('Snyk');
127+
lines.push(header);
128+
129+
// Rows
130+
for (const skill of skills) {
131+
const data = auditData[skill.slug];
132+
const name =
133+
skill.displayName.length > nameWidth
134+
? skill.displayName.slice(0, nameWidth - 1) + '\u2026'
135+
: skill.displayName;
136+
137+
const ath = data?.ath ? riskLabel(data.ath.risk) : pc.dim('--');
138+
const socket = data?.socket ? socketLabel(data.socket) : pc.dim('--');
139+
const snyk = data?.snyk ? riskLabel(data.snyk.risk) : pc.dim('--');
140+
141+
lines.push(padEnd(pc.cyan(name), nameWidth + 2) + padEnd(ath, 18) + padEnd(socket, 18) + snyk);
142+
}
143+
144+
// Footer link
145+
lines.push('');
146+
lines.push(`${pc.dim('Details:')} ${pc.dim(`https://skills.sh/${source}`)}`);
147+
148+
return lines;
149+
}
150+
59151
/**
60152
* Shortens a path for display: replaces homedir with ~ and cwd with .
61153
* Handles both Unix and Windows path separators.
@@ -1586,6 +1678,16 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise<
15861678
selectedSkills = selected as Skill[];
15871679
}
15881680

1681+
// Kick off security audit fetch early (non-blocking) so it runs
1682+
// in parallel with agent selection, scope, and mode prompts.
1683+
const ownerRepoForAudit = getOwnerRepo(parsed);
1684+
const auditPromise = ownerRepoForAudit
1685+
? fetchAuditData(
1686+
ownerRepoForAudit,
1687+
selectedSkills.map((s) => getSkillDisplayName(s))
1688+
)
1689+
: Promise.resolve(null);
1690+
15891691
let targetAgents: AgentType[];
15901692
const validAgents = Object.keys(agents);
15911693

@@ -1760,6 +1862,27 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise<
17601862
console.log();
17611863
p.note(summaryLines.join('\n'), 'Installation Summary');
17621864

1865+
// Await and display security audit results (started earlier in parallel)
1866+
// Wrapped in try/catch so a failed audit fetch never blocks installation.
1867+
try {
1868+
const auditData = await auditPromise;
1869+
if (auditData && ownerRepoForAudit) {
1870+
const securityLines = buildSecurityLines(
1871+
auditData,
1872+
selectedSkills.map((s) => ({
1873+
slug: getSkillDisplayName(s),
1874+
displayName: getSkillDisplayName(s),
1875+
})),
1876+
ownerRepoForAudit
1877+
);
1878+
if (securityLines.length > 0) {
1879+
p.note(securityLines.join('\n'), 'Security Risk Assessments');
1880+
}
1881+
}
1882+
} catch {
1883+
// Silently skip — security info is advisory only
1884+
}
1885+
17631886
if (!options.yes) {
17641887
const confirmed = await p.confirm({ message: 'Proceed with installation?' });
17651888

src/telemetry.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const TELEMETRY_URL = 'https://add-skill.vercel.sh/t';
2+
const AUDIT_URL = 'https://add-skill.vercel.sh/audit';
23

34
interface InstallTelemetryData {
45
event: 'install';
@@ -75,6 +76,50 @@ export function setVersion(version: string): void {
7576
cliVersion = version;
7677
}
7778

79+
// ─── Security audit data ───
80+
81+
export interface PartnerAudit {
82+
risk: 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'unknown';
83+
alerts?: number;
84+
score?: number;
85+
analyzedAt: string;
86+
}
87+
88+
export type SkillAuditData = Record<string, PartnerAudit>;
89+
export type AuditResponse = Record<string, SkillAuditData>;
90+
91+
/**
92+
* Fetch security audit results for skills from the audit API.
93+
* Returns null on any error or timeout — never blocks installation.
94+
*/
95+
export async function fetchAuditData(
96+
source: string,
97+
skillSlugs: string[],
98+
timeoutMs = 3000
99+
): Promise<AuditResponse | null> {
100+
if (skillSlugs.length === 0) return null;
101+
102+
try {
103+
const params = new URLSearchParams({
104+
source,
105+
skills: skillSlugs.join(','),
106+
});
107+
108+
const controller = new AbortController();
109+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
110+
111+
const response = await fetch(`${AUDIT_URL}?${params.toString()}`, {
112+
signal: controller.signal,
113+
});
114+
clearTimeout(timeout);
115+
116+
if (!response.ok) return null;
117+
return (await response.json()) as AuditResponse;
118+
} catch {
119+
return null;
120+
}
121+
}
122+
78123
export function track(data: TelemetryData): void {
79124
if (!isEnabled()) return;
80125

0 commit comments

Comments
 (0)