Bun Spawn -> node child process in tools and seed versioning
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Convert To MP3
|
||||
description: Convert audio files to MP3 320kbps, preserving metadata.
|
||||
version: 2
|
||||
version: 3
|
||||
author: pastilhas
|
||||
tags:
|
||||
- audio
|
||||
@@ -38,10 +38,13 @@ inputs:
|
||||
|
||||
Convert audio files to MP3 320kbps, preserving metadata.
|
||||
|
||||
## Important
|
||||
|
||||
- Do NOT explore, list, or inspect the target path before converting. The tool handles everything internally — file discovery, format detection, and error reporting.
|
||||
- Do NOT use bash, ls, or any other tool. Only use `convert_audio_to_mp3`.
|
||||
- Call the tool exactly once, then report the result. Nothing else.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Determine whether `file_path` points to a single audio file or a directory.
|
||||
2. Call `convert_audio_to_mp3` with the appropriate mode:
|
||||
- Single file: `mode="single"`, `path=file_path`
|
||||
- Directory: `mode="batch"`, `path=file_path`
|
||||
3. Report the result to the user.
|
||||
1. If `file_path` has an audio extension (flac, wav, ogg, etc.), call `convert_audio_to_mp3(mode="single", path=file_path)`. Otherwise call `convert_audio_to_mp3(mode="batch", path=file_path)`.
|
||||
2. Print the tool's output as the final report. Do not add extra commentary.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name: convert_audio_to_mp3
|
||||
label: Convert Audio to MP3
|
||||
description: Convert audio files to MP3 320kbps using ffmpeg, preserving metadata. Supports single file conversion with real-time percentage progress, and batch conversion of an entire artist directory with per-file progress. Use when the user wants to convert FLAC, WAV, OGG, or other audio formats to MP3.
|
||||
version: 2
|
||||
language: typescript
|
||||
inputs:
|
||||
mode:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readdirSync, existsSync, unlinkSync, statSync } from 'node:fs';
|
||||
import { join, extname, basename } from 'node:path';
|
||||
import { spawn, execFile } from 'node:child_process';
|
||||
|
||||
const AUDIO_EXTS = new Set([
|
||||
'flac', 'wav', 'ogg', 'wma', 'aac', 'm4a', 'opus',
|
||||
@@ -13,14 +14,17 @@ function update(onUpdate: OnUpdate | undefined, text: string): void {
|
||||
}
|
||||
|
||||
async function getDuration(filePath: string): Promise<number | null> {
|
||||
const proc = Bun.spawn(
|
||||
['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
|
||||
{ stdout: 'pipe', stderr: 'pipe' },
|
||||
);
|
||||
const text = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
const n = parseFloat(text.trim());
|
||||
return isNaN(n) ? null : n;
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
'ffprobe',
|
||||
['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
|
||||
(err, stdout) => {
|
||||
if (err) return resolve(null);
|
||||
const n = parseFloat(stdout.trim());
|
||||
resolve(isNaN(n) ? null : n);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function scanAudioFiles(dir: string): string[] {
|
||||
@@ -57,60 +61,55 @@ async function convertFile(
|
||||
return { outputFile, success: true, skipped: true };
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
'ffmpeg',
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('ffmpeg', [
|
||||
'-i', inputFile,
|
||||
'-progress', 'pipe:1', // progress data → stdout
|
||||
'-progress', 'pipe:1',
|
||||
'-nostats',
|
||||
'-loglevel', 'error', // only errors → stderr
|
||||
'-loglevel', 'error',
|
||||
'-codec:a', 'libmp3lame',
|
||||
'-b:a', '320k',
|
||||
outputFile,
|
||||
'-y',
|
||||
],
|
||||
{ stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
||||
);
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
// Parse -progress output from stdout for real-time percentage
|
||||
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let lastPercent = -1;
|
||||
let buf = '';
|
||||
let lastPercent = -1;
|
||||
let stderrText = '';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^out_time_us=(\d+)$/);
|
||||
if (m && duration) {
|
||||
const pct = Math.min(100, Math.round((parseInt(m[1]!, 10) / 1_000_000 / duration) * 100));
|
||||
if (pct >= lastPercent + 5) {
|
||||
lastPercent = pct;
|
||||
update(onUpdate, `${label} ${pct}%`);
|
||||
}
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
buf += chunk.toString();
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^out_time_us=(\d+)$/);
|
||||
if (m && duration) {
|
||||
const pct = Math.min(100, Math.round((parseInt(m[1]!, 10) / 1_000_000 / duration) * 100));
|
||||
if (pct >= lastPercent + 5) {
|
||||
lastPercent = pct;
|
||||
update(onUpdate, `${label} ${pct}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// stream closed
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const stderrText = await new Response(proc.stderr).text();
|
||||
await proc.exited;
|
||||
proc.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrText += chunk.toString();
|
||||
});
|
||||
|
||||
if (proc.exitCode !== 0) {
|
||||
if (existsSync(outputFile)) unlinkSync(outputFile);
|
||||
return { outputFile, success: false, error: stderrText.trim() || 'ffmpeg failed' };
|
||||
}
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
if (existsSync(outputFile)) unlinkSync(outputFile);
|
||||
resolve({ outputFile, success: false, error: stderrText.trim() || 'ffmpeg failed' });
|
||||
} else {
|
||||
resolve({ outputFile, success: true });
|
||||
}
|
||||
});
|
||||
|
||||
return { outputFile, success: true };
|
||||
proc.on('error', (err) => {
|
||||
resolve({ outputFile, success: false, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function execute(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SEED_PATH, DATA_PATH } from './data-path';
|
||||
import { parseSeedVersion } from './sync-version';
|
||||
|
||||
const SEED_PROCESSES_DIR = join(SEED_PATH, 'processes');
|
||||
const GLOBAL_PROCESSES_DIR = join(DATA_PATH, 'processes');
|
||||
@@ -16,14 +17,18 @@ export function syncSeedProcesses(): void {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const seedProcessDir = join(SEED_PROCESSES_DIR, entry.name);
|
||||
const processFile = join(seedProcessDir, 'PROCESS.md');
|
||||
if (!existsSync(processFile)) continue;
|
||||
const seedFile = join(seedProcessDir, 'PROCESS.md');
|
||||
if (!existsSync(seedFile)) continue;
|
||||
|
||||
const targetDir = join(GLOBAL_PROCESSES_DIR, entry.name);
|
||||
const targetFile = join(targetDir, 'PROCESS.md');
|
||||
|
||||
if (existsSync(targetDir)) {
|
||||
// Process already exists in DATA_PATH — skip to preserve user edits
|
||||
continue;
|
||||
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
|
||||
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
|
||||
if (seedVersion <= targetVersion) continue;
|
||||
rmSync(targetDir, { recursive: true });
|
||||
console.log(`[processes] Updating seed process: ${entry.name} (v${targetVersion} → v${seedVersion})`);
|
||||
}
|
||||
|
||||
cpSync(seedProcessDir, targetDir, { recursive: true });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SEED_PATH, DATA_PATH } from './data-path';
|
||||
import { parseSeedVersion } from './sync-version';
|
||||
|
||||
const SEED_SKILLS_DIR = join(SEED_PATH, 'skills');
|
||||
const GLOBAL_SKILLS_DIR = join(DATA_PATH, 'skills');
|
||||
@@ -16,14 +17,18 @@ export function syncSeedSkills(): void {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const seedSkillDir = join(SEED_SKILLS_DIR, entry.name);
|
||||
const skillFile = join(seedSkillDir, 'SKILL.md');
|
||||
if (!existsSync(skillFile)) continue;
|
||||
const seedFile = join(seedSkillDir, 'SKILL.md');
|
||||
if (!existsSync(seedFile)) continue;
|
||||
|
||||
const targetDir = join(GLOBAL_SKILLS_DIR, entry.name);
|
||||
const targetFile = join(targetDir, 'SKILL.md');
|
||||
|
||||
if (existsSync(targetDir)) {
|
||||
// Skill already exists in DATA_PATH — skip to preserve user edits
|
||||
continue;
|
||||
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
|
||||
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
|
||||
if (seedVersion <= targetVersion) continue;
|
||||
rmSync(targetDir, { recursive: true });
|
||||
console.log(`[skills] Updating seed skill: ${entry.name} (v${targetVersion} → v${seedVersion})`);
|
||||
}
|
||||
|
||||
cpSync(seedSkillDir, targetDir, { recursive: true });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SEED_PATH, DATA_PATH } from './data-path';
|
||||
import { parseSeedVersion } from './sync-version';
|
||||
|
||||
const SEED_TASKS_DIR = join(SEED_PATH, 'tasks');
|
||||
const GLOBAL_TASKS_DIR = join(DATA_PATH, 'tasks');
|
||||
@@ -16,14 +17,18 @@ export function syncSeedTasks(): void {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const seedTaskDir = join(SEED_TASKS_DIR, entry.name);
|
||||
const taskFile = join(seedTaskDir, 'TASK.md');
|
||||
if (!existsSync(taskFile)) continue;
|
||||
const seedFile = join(seedTaskDir, 'TASK.md');
|
||||
if (!existsSync(seedFile)) continue;
|
||||
|
||||
const targetDir = join(GLOBAL_TASKS_DIR, entry.name);
|
||||
const targetFile = join(targetDir, 'TASK.md');
|
||||
|
||||
if (existsSync(targetDir)) {
|
||||
// Task already exists in DATA_PATH — skip to preserve user edits
|
||||
continue;
|
||||
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
|
||||
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
|
||||
if (seedVersion <= targetVersion) continue;
|
||||
rmSync(targetDir, { recursive: true });
|
||||
console.log(`[tasks] Updating seed task: ${entry.name} (v${targetVersion} → v${seedVersion})`);
|
||||
}
|
||||
|
||||
cpSync(seedTaskDir, targetDir, { recursive: true });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SEED_PATH, DATA_PATH } from './data-path';
|
||||
import { parseSeedVersion } from './sync-version';
|
||||
|
||||
const SEED_TOOLS_DIR = join(SEED_PATH, 'tools');
|
||||
const GLOBAL_TOOLS_DIR = join(DATA_PATH, 'tools');
|
||||
@@ -16,14 +17,18 @@ export function syncSeedTools(): void {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const seedToolDir = join(SEED_TOOLS_DIR, entry.name);
|
||||
const toolFile = join(seedToolDir, 'TOOL.md');
|
||||
if (!existsSync(toolFile)) continue;
|
||||
const seedFile = join(seedToolDir, 'TOOL.md');
|
||||
if (!existsSync(seedFile)) continue;
|
||||
|
||||
const targetDir = join(GLOBAL_TOOLS_DIR, entry.name);
|
||||
const targetFile = join(targetDir, 'TOOL.md');
|
||||
|
||||
if (existsSync(targetDir)) {
|
||||
// Tool already exists in DATA_PATH — skip to preserve user edits
|
||||
continue;
|
||||
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
|
||||
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
|
||||
if (seedVersion <= targetVersion) continue;
|
||||
rmSync(targetDir, { recursive: true });
|
||||
console.log(`[tools] Updating seed tool: ${entry.name} (v${targetVersion} → v${seedVersion})`);
|
||||
}
|
||||
|
||||
cpSync(seedToolDir, targetDir, { recursive: true });
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Extract the `version` field from YAML frontmatter.
|
||||
* Returns 0 if no version is found.
|
||||
*/
|
||||
export function parseSeedVersion(content: string): number {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return 0;
|
||||
const versionMatch = match[1]!.match(/^version:\s*(\d+)/m);
|
||||
return versionMatch ? parseInt(versionMatch[1]!, 10) : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user