chore: bump version to 2.80.27 and update core features
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { isInside } from '../lib/paths';
|
||||
import { logError, logInfo } from '../utils';
|
||||
|
||||
/**
|
||||
* External-tool skill injection.
|
||||
*
|
||||
* Connect_origin's `/api/skill-inject` writes Python tool scripts into a
|
||||
* per-agent `tools/` folder, on the assumption the agent can `<run_command>`
|
||||
* them. ConnectAI doesn't run Python tools — its agent skills are markdown
|
||||
* documents loaded by the sidebar (`<workspace>/.agent/skills/<name>.md`).
|
||||
*
|
||||
* So this service injects **markdown skills** (the ConnectAI primitive), not
|
||||
* .py scripts. The endpoint shape stays similar (name / displayName /
|
||||
* description / content / source) so the same external integrations
|
||||
* (EZER / Agent University) can target either project with a thin adapter.
|
||||
*
|
||||
* What lands on disk per inject call:
|
||||
* .agent/skills/<safeName>.md — markdown body
|
||||
* .agent/skills/<safeName>.meta.json — injectedAt + injectedFrom + display fields
|
||||
*
|
||||
* The `.md` is what the existing sidebar `_sendAgentsList` already discovers.
|
||||
* The sidecar `.meta.json` is read by future UI surfaces (provenance badges)
|
||||
* but is invisible to the legacy loader, so back-compat is preserved.
|
||||
*/
|
||||
|
||||
export interface SkillInjectionRequest {
|
||||
/** Required. Slug-style identifier; will be sanitized to filesystem-safe form. */
|
||||
name: string;
|
||||
/** Required. The markdown body of the skill (system-prompt content). */
|
||||
content: string;
|
||||
/** Optional. Human-friendly display name. Falls back to sanitized `name`. */
|
||||
displayName?: string;
|
||||
/** Optional. One-line description shown in UI hints. */
|
||||
description?: string;
|
||||
/** Optional. External-source tag, e.g. `"ezer"` / `"agent-university"`. */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface SkillInjectionResult {
|
||||
/** Sanitized name actually used on disk. */
|
||||
safeName: string;
|
||||
/** Absolute path to the written markdown file. */
|
||||
filePath: string;
|
||||
/** Absolute path to the sidecar metadata file. */
|
||||
metaPath: string;
|
||||
}
|
||||
|
||||
export class SkillInjectionError extends Error {
|
||||
constructor(message: string, public readonly code: string) {
|
||||
super(message);
|
||||
this.name = 'SkillInjectionError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ISkillInjectionService {
|
||||
inject(req: SkillInjectionRequest): Promise<SkillInjectionResult>;
|
||||
}
|
||||
|
||||
export interface SkillInjectionDeps {
|
||||
/** Resolves the skills directory at the time of each call (must be absolute). */
|
||||
resolveSkillsDir: () => string;
|
||||
/** Optional fs override for unit tests; defaults to node:fs. */
|
||||
fsImpl?: Pick<typeof fs, 'existsSync' | 'mkdirSync' | 'writeFileSync'>;
|
||||
/** Optional notification hook called on successful injection. */
|
||||
onInjected?: (result: SkillInjectionResult, req: SkillInjectionRequest) => void;
|
||||
}
|
||||
|
||||
/** Conservative skill-name sanitizer. Rejects empty / overlong / shell-unfriendly inputs. */
|
||||
export function sanitizeSkillName(raw: string): string {
|
||||
if (typeof raw !== 'string') return '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
// Strip extensions if caller passed "foo.md" by mistake
|
||||
const noExt = trimmed.replace(/\.(md|markdown)$/i, '');
|
||||
// Allow only ASCII alphanumerics, `_`, `-`, `.` — block path separators,
|
||||
// shell metas, and unicode that could confuse filesystems.
|
||||
const safe = noExt.replace(/[^a-zA-Z0-9_.\-]/g, '_').replace(/^[._]+|[._]+$/g, '');
|
||||
return safe.slice(0, 80);
|
||||
}
|
||||
|
||||
export class FileSystemSkillInjectionService implements ISkillInjectionService {
|
||||
private readonly _fs: NonNullable<SkillInjectionDeps['fsImpl']>;
|
||||
|
||||
constructor(private readonly deps: SkillInjectionDeps) {
|
||||
this._fs = deps.fsImpl ?? fs;
|
||||
}
|
||||
|
||||
async inject(req: SkillInjectionRequest): Promise<SkillInjectionResult> {
|
||||
const safeName = sanitizeSkillName(req.name);
|
||||
if (!safeName) {
|
||||
throw new SkillInjectionError(
|
||||
'Skill name is empty after sanitization. Use ASCII letters, digits, "_", "-", or "." only.',
|
||||
'INVALID_NAME'
|
||||
);
|
||||
}
|
||||
if (typeof req.content !== 'string' || !req.content.trim()) {
|
||||
throw new SkillInjectionError('Skill content must be a non-empty markdown string.', 'EMPTY_CONTENT');
|
||||
}
|
||||
|
||||
const skillsDir = this.deps.resolveSkillsDir();
|
||||
if (!skillsDir) {
|
||||
throw new SkillInjectionError(
|
||||
'Agent skills directory is not available. Open a workspace folder first.',
|
||||
'NO_SKILLS_DIR'
|
||||
);
|
||||
}
|
||||
|
||||
const targetMd = path.join(skillsDir, `${safeName}.md`);
|
||||
const targetMeta = path.join(skillsDir, `${safeName}.meta.json`);
|
||||
|
||||
if (!isInside(skillsDir, targetMd) || !isInside(skillsDir, targetMeta)) {
|
||||
// Defense in depth: sanitizer should already block traversal, but the
|
||||
// path math runs again here in case skillsDir resolves to something
|
||||
// surprising (symlink, weird casing on Windows, etc.).
|
||||
throw new SkillInjectionError('Refusing to write outside the skills directory.', 'PATH_ESCAPE');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this._fs.existsSync(skillsDir)) {
|
||||
this._fs.mkdirSync(skillsDir, { recursive: true });
|
||||
}
|
||||
this._fs.writeFileSync(targetMd, req.content, 'utf8');
|
||||
|
||||
const meta = {
|
||||
name: safeName,
|
||||
displayName: (req.displayName || '').trim() || safeName,
|
||||
description: (req.description || '').trim() || '',
|
||||
injectedAt: new Date().toISOString(),
|
||||
injectedFrom: (req.source || '').trim() || 'external',
|
||||
};
|
||||
this._fs.writeFileSync(targetMeta, JSON.stringify(meta, null, 2), 'utf8');
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e);
|
||||
logError('Skill injection write failed.', { safeName, skillsDir, error: msg });
|
||||
throw new SkillInjectionError(`Failed to write skill files: ${msg}`, 'WRITE_FAILED');
|
||||
}
|
||||
|
||||
const result: SkillInjectionResult = { safeName, filePath: targetMd, metaPath: targetMeta };
|
||||
logInfo('Skill injected.', { safeName, source: req.source, skillsDir });
|
||||
this.deps.onInjected?.(result, req);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user