move tasks to marketplace, sync native tasks on bootstrap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
name: Build Discography
|
|
||||||
description: Identify all albums in an artist folder, rename directories, fetch detailed info and cover art for each.
|
|
||||||
version: 1
|
|
||||||
mode: pipeline
|
|
||||||
triggers:
|
|
||||||
- type: directory
|
|
||||||
inputs:
|
|
||||||
artist_name:
|
|
||||||
type: string
|
|
||||||
description: Artist / band name
|
|
||||||
autofill: entry_name
|
|
||||||
steps:
|
|
||||||
- task: convert-audio
|
|
||||||
inputs:
|
|
||||||
file_path: .
|
|
||||||
target_format: mp3
|
|
||||||
delete_source: "true"
|
|
||||||
- task: clean-playlist-files
|
|
||||||
inputs:
|
|
||||||
file_path: .
|
|
||||||
- task: prepare-discography
|
|
||||||
inputs:
|
|
||||||
artist_name: ${artist_name}
|
|
||||||
- task: fetch-album-info
|
|
||||||
foreach: subdirectory
|
|
||||||
concurrency: 5
|
|
||||||
skip_if: album-info.md
|
|
||||||
inputs:
|
|
||||||
artist_name: ${artist_name}
|
|
||||||
album_name: ${folder_name}
|
|
||||||
- task: tag-album
|
|
||||||
foreach: subdirectory
|
|
||||||
concurrency: 5
|
|
||||||
inputs:
|
|
||||||
artist_name: ${artist_name}
|
|
||||||
album_name: ${folder_name}
|
|
||||||
---
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
name: Clean Playlist Files
|
|
||||||
description: Recursively delete .cue, .m3u, .m3u8, .pls, .wpl, .xspf and other playlist files.
|
|
||||||
version: 1
|
|
||||||
mode: script
|
|
||||||
language: bash
|
|
||||||
triggers:
|
|
||||||
- type: directory
|
|
||||||
inputs:
|
|
||||||
file_path:
|
|
||||||
type: string
|
|
||||||
description: Path to a directory to clean.
|
|
||||||
args: [file_path]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Clean Playlist Files
|
|
||||||
|
|
||||||
Remove playlist and cue sheet files from a directory tree.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
EXTENSIONS="cue|m3u|m3u8|pls|wpl|xspf|nfo|txt|log|accurip|sfv|md5"
|
|
||||||
|
|
||||||
TARGET="${1:-${INPUT_FILE_PATH:-}}"
|
|
||||||
|
|
||||||
if [[ -z "$TARGET" ]]; then
|
|
||||||
echo "Error: No directory path provided" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -d "$TARGET" ]]; then
|
|
||||||
echo "Error: Not a directory: $TARGET" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
deleted=0
|
|
||||||
|
|
||||||
while IFS= read -r -d '' file; do
|
|
||||||
echo "Deleting: $file"
|
|
||||||
rm "$file"
|
|
||||||
deleted=$((deleted + 1))
|
|
||||||
done < <(find "$TARGET" -type f -regextype posix-extended -iregex ".*\.($EXTENSIONS)" -print0 | sort -z)
|
|
||||||
|
|
||||||
if [[ $deleted -eq 0 ]]; then
|
|
||||||
echo "No playlist/cue files found in: $TARGET"
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "Summary: $deleted files deleted"
|
|
||||||
fi
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
name: Convert Audio
|
|
||||||
description: Convert audio files between formats, preserving metadata.
|
|
||||||
version: 1
|
|
||||||
mode: script
|
|
||||||
language: bash
|
|
||||||
triggers:
|
|
||||||
- type: file
|
|
||||||
extensions:
|
|
||||||
- mp3
|
|
||||||
- flac
|
|
||||||
- wav
|
|
||||||
- ogg
|
|
||||||
- wma
|
|
||||||
- aac
|
|
||||||
- m4a
|
|
||||||
- opus
|
|
||||||
- aiff
|
|
||||||
- aif
|
|
||||||
- ape
|
|
||||||
- wv
|
|
||||||
- alac
|
|
||||||
- dsf
|
|
||||||
- dff
|
|
||||||
- type: directory
|
|
||||||
inputs:
|
|
||||||
file_path:
|
|
||||||
type: string
|
|
||||||
description: Path to an audio file or directory to convert.
|
|
||||||
target_format:
|
|
||||||
type: string
|
|
||||||
description: Target format
|
|
||||||
default: mp3
|
|
||||||
options:
|
|
||||||
- mp3
|
|
||||||
- flac
|
|
||||||
- wav
|
|
||||||
- ogg
|
|
||||||
- aac
|
|
||||||
- opus
|
|
||||||
delete_source:
|
|
||||||
type: boolean
|
|
||||||
description: Delete source files after successful conversion.
|
|
||||||
default: false
|
|
||||||
args: [file_path]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Convert Audio
|
|
||||||
|
|
||||||
Convert audio files between formats using ffmpeg, preserving metadata.
|
|
||||||
Supports single file conversion and batch directory conversion.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── Task: Convert Audio ──
|
|
||||||
|
|
||||||
EXTENSIONS="mp3|flac|wav|ogg|wma|aac|m4a|opus|aiff|aif|ape|wv|alac|dsf|dff"
|
|
||||||
TARGET_FORMAT="${INPUT_TARGET_FORMAT:-mp3}"
|
|
||||||
DELETE_SOURCE="${INPUT_DELETE_SOURCE:-false}"
|
|
||||||
|
|
||||||
ffmpeg_args_for_format() {
|
|
||||||
case "$1" in
|
|
||||||
mp3) echo "-codec:a libmp3lame -b:a 320k -id3v2_version 3" ;;
|
|
||||||
flac) echo "-codec:a flac" ;;
|
|
||||||
wav) echo "-codec:a pcm_s16le" ;;
|
|
||||||
ogg) echo "-codec:a libvorbis -q:a 6" ;;
|
|
||||||
aac) echo "-codec:a aac -b:a 256k" ;;
|
|
||||||
opus) echo "-codec:a libopus -b:a 192k" ;;
|
|
||||||
*) echo "Unsupported format: $1" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
FFMPEG_ARGS=$(ffmpeg_args_for_format "$TARGET_FORMAT") || exit 1
|
|
||||||
|
|
||||||
process_file() {
|
|
||||||
local input="$1"
|
|
||||||
local ext="${input##*.}"
|
|
||||||
local dir
|
|
||||||
dir="$(dirname "$input")"
|
|
||||||
local base
|
|
||||||
base="$(basename "$input" ".$ext")"
|
|
||||||
local output="$dir/$base.$TARGET_FORMAT"
|
|
||||||
|
|
||||||
if [[ "${ext,,}" == "$TARGET_FORMAT" ]]; then
|
|
||||||
echo "Skipping (already $TARGET_FORMAT): $input"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f "$output" ]]; then
|
|
||||||
echo "Skipping (output exists): $output"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Converting: $input → $output"
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
if ffmpeg -nostdin -i "$input" $FFMPEG_ARGS -map_metadata 0 -y "$output" 2>/dev/null; then
|
|
||||||
echo " Done"
|
|
||||||
if [[ "$DELETE_SOURCE" == "true" ]]; then
|
|
||||||
rm "$input"
|
|
||||||
echo " Deleted source"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " Failed" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Template: File Processor ──
|
|
||||||
|
|
||||||
TARGET="${1:-${INPUT_FILE_PATH:-}}"
|
|
||||||
|
|
||||||
if [[ -z "$TARGET" ]]; then
|
|
||||||
echo "Error: No file or directory path provided" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -e "$TARGET" ]]; then
|
|
||||||
echo "Error: Path does not exist: $TARGET" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
converted=0
|
|
||||||
failed=0
|
|
||||||
|
|
||||||
if [[ -f "$TARGET" ]]; then
|
|
||||||
if process_file "$TARGET"; then
|
|
||||||
converted=$((converted + 1))
|
|
||||||
else
|
|
||||||
failed=$((failed + 1))
|
|
||||||
fi
|
|
||||||
elif [[ -d "$TARGET" ]]; then
|
|
||||||
while IFS= read -r -d '' file; do
|
|
||||||
if process_file "$file"; then
|
|
||||||
converted=$((converted + 1))
|
|
||||||
else
|
|
||||||
failed=$((failed + 1))
|
|
||||||
fi
|
|
||||||
done < <(find "$TARGET" -type f -regextype posix-extended -iregex ".*\.($EXTENSIONS)" -print0 | sort -z)
|
|
||||||
|
|
||||||
if [[ $converted -eq 0 && $failed -eq 0 ]]; then
|
|
||||||
echo "No matching files found in: $TARGET"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Error: Not a file or directory: $TARGET" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Summary: $converted processed, $failed failed"
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
---
|
|
||||||
name: Fetch Album Info
|
|
||||||
description: Search the web for album information and save a detailed info file.
|
|
||||||
version: 1
|
|
||||||
mode: agentic
|
|
||||||
triggers:
|
|
||||||
- type: directory
|
|
||||||
inputs:
|
|
||||||
artist_name:
|
|
||||||
type: string
|
|
||||||
description: Artist name
|
|
||||||
autofill: entry_name
|
|
||||||
album_name:
|
|
||||||
type: string
|
|
||||||
description: Album name
|
|
||||||
autofill: entry_name
|
|
||||||
---
|
|
||||||
|
|
||||||
# Fetch Album Info
|
|
||||||
|
|
||||||
You are given an artist name and album name. Your job is to find comprehensive information about this album from the web.
|
|
||||||
|
|
||||||
## Process
|
|
||||||
|
|
||||||
1. **Search** for the album using `web_search` with queries like `"<artist> <album> wikipedia"`, `"<artist> <album> musicbrainz"`, `"<artist> <album> allmusic"`.
|
|
||||||
2. **Fetch** the most relevant pages using `web_fetch` to extract detailed information.
|
|
||||||
3. **Cross-reference** multiple sources to get the most accurate and complete data.
|
|
||||||
4. **Download** the front cover image and save it as `cover.{ext}` (matching the image format, e.g. `cover.jpg`) in the target directory. Overwrite if it already exists.
|
|
||||||
5. **Save** the result as an `album-info.md` file in the target directory (provided in Context).
|
|
||||||
|
|
||||||
## Information to collect
|
|
||||||
|
|
||||||
- **Artist** name (canonical/correct spelling)
|
|
||||||
- **Album** title (canonical/correct spelling)
|
|
||||||
- **Release year** (original release)
|
|
||||||
- **Genre(s)** and subgenres
|
|
||||||
- **Label** / record company
|
|
||||||
- **Tracklist** — for each track: number, title, duration. For multi-disc releases, organize by disc.
|
|
||||||
- **Total duration**
|
|
||||||
- **Front cover image URL** — find the highest quality cover art URL available (Wikipedia, MusicBrainz cover art archive)
|
|
||||||
- **Credits** — producer, engineer, notable musicians (if available)
|
|
||||||
- **Additional notes** — compilation info, remaster details, notable facts
|
|
||||||
|
|
||||||
## Output format
|
|
||||||
|
|
||||||
Write a markdown file named `album-info.md` in the **target directory** (from Context) with this structure:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Artist — Album Title
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| Artist | ... |
|
|
||||||
| Album | ... |
|
|
||||||
| Year | ... |
|
|
||||||
| Genre | ... |
|
|
||||||
| Label | ... |
|
|
||||||
| Duration | ... |
|
|
||||||
|
|
||||||
## Tracklist
|
|
||||||
|
|
||||||
### Disc 1
|
|
||||||
| # | Title | Duration |
|
|
||||||
|---|-------|----------|
|
|
||||||
| 1 | ... | 0:00 |
|
|
||||||
|
|
||||||
### Disc 2
|
|
||||||
...
|
|
||||||
|
|
||||||
## Credits
|
|
||||||
- Producer: ...
|
|
||||||
- ...
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
If there is only one disc, omit the "Disc 1" heading and just use a flat tracklist.
|
|
||||||
|
|
||||||
## Cover art download
|
|
||||||
|
|
||||||
Once you have a cover image URL, download it to the target directory using curl:
|
|
||||||
```
|
|
||||||
curl -sL -o "<target_dir>/cover.<ext>" "<image_url>"
|
|
||||||
```
|
|
||||||
Use the correct extension based on the image format (jpg, png, etc.). Overwrite any existing file.
|
|
||||||
In the `album-info.md`, keep the original remote URL in the image tag: ``.
|
|
||||||
|
|
||||||
## Important
|
|
||||||
|
|
||||||
- Prefer Wikipedia for context, genre classification, credits, and tracklist data.
|
|
||||||
- Use MusicBrainz / Cover Art Archive for high-quality cover image URLs and precise edition identification.
|
|
||||||
- Use AllMusic as an additional source for credits and reviews.
|
|
||||||
- If information conflicts between sources, prefer Wikipedia for metadata and MusicBrainz for tracklist data.
|
|
||||||
- Do NOT make up information. If something is not found, omit it.
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
---
|
|
||||||
name: Prepare Discography
|
|
||||||
description: Scan an artist's album folders, identify exact release editions, and produce a discography summary.
|
|
||||||
version: 1
|
|
||||||
mode: agentic
|
|
||||||
triggers:
|
|
||||||
- type: directory
|
|
||||||
inputs:
|
|
||||||
artist_name:
|
|
||||||
type: string
|
|
||||||
description: Artist / band name
|
|
||||||
autofill: entry_name
|
|
||||||
---
|
|
||||||
|
|
||||||
# Prepare Discography
|
|
||||||
|
|
||||||
You are given an artist/band directory containing one subfolder per album. Your job is to identify the **exact release edition** of each album and produce a `discography.md` summary file.
|
|
||||||
|
|
||||||
## Why this matters
|
|
||||||
|
|
||||||
Album folders often contain special editions, anniversary reissues, deluxe multi-disc sets, etc. A naive search for "The Doors 1967" returns the original 11-track release, but the folder might contain a 50th Anniversary 3CD edition with 30+ tracks. You must identify the **specific edition** the user has.
|
|
||||||
|
|
||||||
## Process
|
|
||||||
|
|
||||||
For each album subfolder in the artist directory:
|
|
||||||
|
|
||||||
### 1. Analyze local signals
|
|
||||||
|
|
||||||
Gather as much information as possible from the folder itself **before** searching the web:
|
|
||||||
|
|
||||||
- **Folder name**: often contains year and album title, sometimes edition hints like `(3CD)`, `(Deluxe)`, `(Remaster)`
|
|
||||||
- **Disc structure**: count `CD1/`, `CD2/`, etc. subdirectories — this tells you how many discs the release has
|
|
||||||
- **Track count per disc**: list audio files in each disc folder (or root if single-disc)
|
|
||||||
- **Track names**: the filenames often contain track titles (e.g. `01 - Break on Through.flac`)
|
|
||||||
- **Audio tags**: use the `mutagen` tool with `action: read` on 1-2 representative tracks per disc to get tagged metadata (artist, album, year, genre, label, etc.)
|
|
||||||
- **Existing files**: check for `album-info.md`, `Front.jpg`, `cover.jpg`, or `Scans/` — these provide additional context
|
|
||||||
|
|
||||||
### 2. Build a search profile
|
|
||||||
|
|
||||||
From the local signals, construct a profile:
|
|
||||||
- Artist name (from tags or folder name)
|
|
||||||
- Album title (from tags or folder name)
|
|
||||||
- Release year (from tags or folder name)
|
|
||||||
- Number of discs
|
|
||||||
- Track count per disc
|
|
||||||
- Any edition keywords (deluxe, remaster, anniversary, etc.)
|
|
||||||
|
|
||||||
### 3. Search for the exact release
|
|
||||||
|
|
||||||
Use `web_search` to find the specific edition:
|
|
||||||
- Search Wikipedia first: `"<artist> <album> wikipedia"` — Wikipedia often has edition/reissue details
|
|
||||||
- Search MusicBrainz: `"<artist> <album> musicbrainz"` — MusicBrainz catalogs every pressing and edition
|
|
||||||
- If the disc/track count doesn't match the first result, refine: `"<artist> <album> deluxe 3CD"` or similar
|
|
||||||
- Use `web_fetch` on the most relevant pages to confirm the tracklist matches your local files
|
|
||||||
|
|
||||||
### 4. Confirm the match
|
|
||||||
|
|
||||||
Compare the web result against local signals:
|
|
||||||
- Does the disc count match?
|
|
||||||
- Does the track count per disc match (approximately)?
|
|
||||||
- Do track names align?
|
|
||||||
- If there's a mismatch, search for alternative editions until you find the best match
|
|
||||||
|
|
||||||
### 5. Rename the album folder
|
|
||||||
|
|
||||||
Once you have confidently identified the release, rename the album folder to the standardized format:
|
|
||||||
|
|
||||||
```
|
|
||||||
[YYYY] Album Title (Edition Marker)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **YYYY** = original release year (NOT the reissue/special edition year)
|
|
||||||
- **Album Title** = canonical album name
|
|
||||||
- **Edition Marker** = only if it's not the standard original release. Examples: `50th Anniversary 3CD Deluxe Edition`, `2017 Remaster`, `Deluxe Edition`
|
|
||||||
- If it IS the plain original release, omit the parenthetical: `[1967] The Doors`
|
|
||||||
|
|
||||||
Use `mv` via Bash to rename. Do this **before** writing discography.md so the file references the new folder names.
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Write a `discography.md` file in the **artist directory** (the target directory from Context) with this structure:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Artist Name — Discography
|
|
||||||
|
|
||||||
## Albums
|
|
||||||
|
|
||||||
### [YYYY] Album Title (Edition Marker)
|
|
||||||
- **Folder**: `[YYYY] Album Title (Edition Marker)/`
|
|
||||||
- **Format**: FLAC / MP3 / Mixed
|
|
||||||
- **Discs**: N
|
|
||||||
- **Tracks**: N (or N per disc: D1: X, D2: Y, ...)
|
|
||||||
- **Year**: YYYY (original release) / YYYY (this edition)
|
|
||||||
- **Label**: ...
|
|
||||||
- **Genre**: ...
|
|
||||||
- **Edition**: 50th Anniversary Deluxe Edition / Original / Remaster / etc.
|
|
||||||
- **Wikipedia**: https://en.wikipedia.org/wiki/... (if found)
|
|
||||||
- **MusicBrainz**: https://musicbrainz.org/release/... (if found)
|
|
||||||
- **Notes**: ...
|
|
||||||
|
|
||||||
### [YYYY] Another Album
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
Sort albums chronologically by original release year.
|
|
||||||
|
|
||||||
Include **direct URLs** to the sources used (Discogs release page, Wikipedia article, MusicBrainz release) — these will be used by downstream tasks to avoid redundant searching.
|
|
||||||
|
|
||||||
## Important
|
|
||||||
|
|
||||||
- **Accuracy over speed**: it's better to correctly identify 3 out of 4 albums than to guess all 4 wrong
|
|
||||||
- **Wikipedia and MusicBrainz are your primary sources** for edition identification
|
|
||||||
- **Use mutagen sparingly**: read 1-2 tracks per disc, not every file
|
|
||||||
- **Track count is the strongest signal** for distinguishing editions — a 3CD set with 10+12+8 tracks is very different from a single-disc 11-track original
|
|
||||||
- **Folder name hints**: `(3CD)`, `(2LP)`, `(Deluxe)`, `(Remaster)`, `(Anniversary)` in the folder name are strong clues
|
|
||||||
- **Do NOT make up information**. If you cannot confidently identify an edition, note what you found and flag the uncertainty
|
|
||||||
- The artist name in the folder may be formatted as `Last, First` or `Name, The` — normalize it when searching (e.g. `Doors, the` → `The Doors`)
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
---
|
|
||||||
name: Tag Album
|
|
||||||
description: Rename track files and set ID3 tags based on album-info.md metadata.
|
|
||||||
version: 1
|
|
||||||
mode: agentic
|
|
||||||
triggers:
|
|
||||||
- type: directory
|
|
||||||
inputs:
|
|
||||||
artist_name:
|
|
||||||
type: string
|
|
||||||
description: Artist name
|
|
||||||
autofill: entry_name
|
|
||||||
album_name:
|
|
||||||
type: string
|
|
||||||
description: Album name
|
|
||||||
autofill: entry_name
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tag Album
|
|
||||||
|
|
||||||
You are given an artist name and album name. Your job is to rename audio files and set proper ID3 tags using the metadata from `album-info.md` in the target directory.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
The target directory (from Context) must contain:
|
|
||||||
- Audio files (`.mp3`)
|
|
||||||
- An `album-info.md` file (produced by the fetch-album-info task)
|
|
||||||
- Optionally a cover image (`cover.jpg`, `cover.png`, `front.jpg`, or similar)
|
|
||||||
|
|
||||||
If `album-info.md` does not exist, stop and report that the album info must be fetched first.
|
|
||||||
|
|
||||||
## Process
|
|
||||||
|
|
||||||
### 1. Read album-info.md
|
|
||||||
|
|
||||||
Parse the `album-info.md` file to extract:
|
|
||||||
- **Artist** (canonical name from the metadata table)
|
|
||||||
- **Album** title (canonical name from the metadata table)
|
|
||||||
- **Year** (from the metadata table)
|
|
||||||
- **Genre** (from the metadata table)
|
|
||||||
- **Tracklist** — track numbers, titles, and disc numbers (if multi-disc)
|
|
||||||
|
|
||||||
### 2. Inventory existing files
|
|
||||||
|
|
||||||
List all `.mp3` files in the target directory (and `Disc N/` subdirectories for multi-disc albums).
|
|
||||||
|
|
||||||
Match each existing file to a track in the tracklist. Use track number, partial title match, or positional order to establish the mapping. If the album has multiple discs, match within each `Disc N/` subdirectory.
|
|
||||||
|
|
||||||
### 3. Rename files
|
|
||||||
|
|
||||||
Rename each audio file to the standardized format:
|
|
||||||
|
|
||||||
```
|
|
||||||
NNN - Track Title.mp3
|
|
||||||
```
|
|
||||||
|
|
||||||
Where:
|
|
||||||
- `NNN` is the track number zero-padded to 3 digits (e.g. `001`, `012`)
|
|
||||||
- `Track Title` is the canonical title from `album-info.md`
|
|
||||||
|
|
||||||
**Filename safety rules** — these characters are illegal on Windows and must be replaced:
|
|
||||||
- `:` → ` -` (space dash)
|
|
||||||
- `?` → removed
|
|
||||||
- `"` → removed
|
|
||||||
- `*`, `<`, `>`, `|`, `\`, `/` → removed
|
|
||||||
|
|
||||||
Use `mv` to rename. Work within each disc subdirectory if the album is multi-disc.
|
|
||||||
|
|
||||||
### 4. Set ID3 tags
|
|
||||||
|
|
||||||
Use the `mutagen` tool to write tags on each track. Required tags:
|
|
||||||
|
|
||||||
| Tag | Value |
|
|
||||||
|-----|-------|
|
|
||||||
| TIT2 | Track title (from tracklist, NO track number prefix) |
|
|
||||||
| TPE1 | Artist name (use the `artist_name` input exactly as provided) |
|
|
||||||
| TPE2 | Same as TPE1 |
|
|
||||||
| TALB | Album title |
|
|
||||||
| TRCK | `track/total` (e.g. `3/12`) |
|
|
||||||
| TDRC | Release year (4-digit) |
|
|
||||||
| TCON | Genre(s) from album-info |
|
|
||||||
|
|
||||||
For multi-disc albums, also set:
|
|
||||||
| TPOS | `disc/total` (e.g. `1/2`) |
|
|
||||||
|
|
||||||
Use the `mutagen` tool's `write` action. Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
mutagen write --path "001 - Track Name.mp3" --tags '{"TIT2": "Track Name", "TPE1": "Artist", "TPE2": "Artist", "TALB": "Album", "TRCK": "1/12", "TDRC": "1967", "TCON": "Rock"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Embed cover art
|
|
||||||
|
|
||||||
If a cover image exists in the target directory (check for `cover.jpg`, `cover.png`, `front.jpg`, `Front.jpg`, `Cover/Cover.jpg`, or any image file that looks like album art), embed it into every track using the `mutagen` tool's `embed_cover` action:
|
|
||||||
|
|
||||||
```
|
|
||||||
mutagen embed_cover --path "001 - Track Name.mp3" --image "cover.jpg"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Verify
|
|
||||||
|
|
||||||
After all files are renamed and tagged, read back the tags of the first and last track using `mutagen read` to confirm the tags were written correctly. Report a summary of what was done:
|
|
||||||
- Number of tracks processed
|
|
||||||
- Any files that could not be matched or tagged
|
|
||||||
- Whether cover art was embedded
|
|
||||||
|
|
||||||
## Important
|
|
||||||
|
|
||||||
- Always read `album-info.md` as the source of truth for metadata — do NOT use web search.
|
|
||||||
- Do NOT modify the audio content, only metadata and filenames.
|
|
||||||
- Preserve disc subdirectory structure for multi-disc albums.
|
|
||||||
- If a track file cannot be matched to a tracklist entry, skip it and report it.
|
|
||||||
- Track titles in TIT2 must NOT include track numbers (e.g. "Song Name", not "01 - Song Name").
|
|
||||||
- Use the `artist_name` input parameter exactly as provided for TPE1 and TPE2 — do NOT reformat it or use the artist name from `album-info.md`. The user organizes their library alphabetically by this value.
|
|
||||||
- Use the canonical album title from `album-info.md` for TALB.
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
/**
|
|
||||||
* Seed native tasks into the database.
|
|
||||||
* Run: bun src/databases/officer_db/seed-tasks.ts
|
|
||||||
*/
|
|
||||||
import { eq, and } from 'drizzle-orm';
|
|
||||||
import { db } from './src/db';
|
|
||||||
import { tasks } from './src/schema/agent-items';
|
|
||||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
|
|
||||||
const SEED_TASKS_DIR = resolve(import.meta.dir, '../../../seed/tasks');
|
|
||||||
|
|
||||||
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
|
|
||||||
|
|
||||||
function parseFrontmatter(content: string) {
|
|
||||||
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
||||||
if (!match) return { meta: {} as Record<string, unknown>, body: content };
|
|
||||||
|
|
||||||
const yaml = match[1]!;
|
|
||||||
const body = match[2]!;
|
|
||||||
const meta: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
meta.name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
|
||||||
meta.description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
|
||||||
meta.mode = yaml.match(/^mode:\s*(.+)$/m)?.[1]?.trim() ?? 'agentic';
|
|
||||||
meta.language = yaml.match(/^language:\s*(.+)$/m)?.[1]?.trim() ?? undefined;
|
|
||||||
meta.version = parseInt(yaml.match(/^version:\s*(.+)$/m)?.[1]?.trim() ?? '1', 10);
|
|
||||||
|
|
||||||
// Parse args
|
|
||||||
const argsMatch = yaml.match(/^args:\s*\[([^\]]*)\]/m);
|
|
||||||
meta.args = argsMatch ? argsMatch[1]!.split(',').map((a) => a.trim()).filter(Boolean) : undefined;
|
|
||||||
|
|
||||||
// Parse triggers
|
|
||||||
const triggers: TriggerConfig[] = [];
|
|
||||||
const triggerMatch = yaml.match(/^triggers?:\s*\n((?:[ \t]+.+\n?)*)/m);
|
|
||||||
if (triggerMatch) {
|
|
||||||
const items = triggerMatch[1]!.split(/(?=^\s+-\s*type:)/m);
|
|
||||||
for (const item of items) {
|
|
||||||
const type = item.match(/type:\s*(.+)/)?.[1]?.trim();
|
|
||||||
if (type === 'directory') {
|
|
||||||
triggers.push({ type: 'directory' });
|
|
||||||
} else if (type === 'file') {
|
|
||||||
const extBlock = item.match(/extensions:\s*\n((?:\s+-\s*.+\n?)*)/);
|
|
||||||
const extensions = extBlock ? [...extBlock[1]!.matchAll(/^\s+-\s*(.+)$/gm)].map((m) => m[1]!.trim()) : [];
|
|
||||||
if (extensions.length > 0) triggers.push({ type: 'file', extensions });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
meta.trigger = triggers.length > 0 ? triggers : undefined;
|
|
||||||
|
|
||||||
// Parse inputs
|
|
||||||
const inputsMatch = yaml.match(/^inputs:\s*\n((?:[ \t]+.+\n?)*)/m);
|
|
||||||
if (inputsMatch) {
|
|
||||||
const inputBlock = inputsMatch[1]!;
|
|
||||||
const inputEntries: Record<string, Record<string, string | string[]>> = {};
|
|
||||||
let currentInput: string | null = null;
|
|
||||||
let currentListKey: string | null = null;
|
|
||||||
let currentList: string[] = [];
|
|
||||||
|
|
||||||
const flushList = () => {
|
|
||||||
if (currentInput && currentListKey && currentList.length > 0) {
|
|
||||||
inputEntries[currentInput]![currentListKey] = currentList;
|
|
||||||
}
|
|
||||||
currentListKey = null;
|
|
||||||
currentList = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const line of inputBlock.split('\n')) {
|
|
||||||
const topMatch = line.match(/^\s{2}(\w[\w_-]*):\s*$/);
|
|
||||||
if (topMatch) {
|
|
||||||
flushList();
|
|
||||||
currentInput = topMatch[1]!;
|
|
||||||
inputEntries[currentInput] = {};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// List item (6 spaces + dash)
|
|
||||||
const listItemMatch = line.match(/^\s{6}-\s*(.+)$/);
|
|
||||||
if (listItemMatch && currentInput && currentListKey) {
|
|
||||||
currentList.push(listItemMatch[1]!.trim());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Property with value or start of list
|
|
||||||
const propMatch = line.match(/^\s{4}(\w[\w_-]*):\s*(.*)$/);
|
|
||||||
if (propMatch && currentInput) {
|
|
||||||
flushList();
|
|
||||||
const value = propMatch[2]!.trim();
|
|
||||||
if (value === '') {
|
|
||||||
// Start of a list (e.g. "options:")
|
|
||||||
currentListKey = propMatch[1]!;
|
|
||||||
currentList = [];
|
|
||||||
} else {
|
|
||||||
inputEntries[currentInput]![propMatch[1]!] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushList();
|
|
||||||
if (Object.keys(inputEntries).length > 0) {
|
|
||||||
meta.inputs = inputEntries;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse pipeline steps
|
|
||||||
const stepsMatch = yaml.match(/^steps:\s*\n((?:[ \t]+.+\n?)*)/m);
|
|
||||||
if (stepsMatch) {
|
|
||||||
const stepsBlock = stepsMatch[1]!;
|
|
||||||
const steps: Record<string, unknown>[] = [];
|
|
||||||
let currentStep: Record<string, unknown> | null = null;
|
|
||||||
let inInputs = false;
|
|
||||||
let stepInputs: Record<string, string> = {};
|
|
||||||
|
|
||||||
for (const line of stepsBlock.split('\n')) {
|
|
||||||
// New step entry (2 spaces + dash)
|
|
||||||
const stepStart = line.match(/^\s{2}-\s+task:\s*(.+)$/);
|
|
||||||
if (stepStart) {
|
|
||||||
if (currentStep) {
|
|
||||||
if (Object.keys(stepInputs).length > 0) currentStep.inputs = stepInputs;
|
|
||||||
steps.push(currentStep);
|
|
||||||
}
|
|
||||||
currentStep = { task: stepStart[1]!.trim() };
|
|
||||||
stepInputs = {};
|
|
||||||
inInputs = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!currentStep) continue;
|
|
||||||
|
|
||||||
// Step-level properties (4 spaces)
|
|
||||||
const propMatch = line.match(/^\s{4}(\w[\w_-]*):\s*(.*)$/);
|
|
||||||
if (propMatch) {
|
|
||||||
const key = propMatch[1]!;
|
|
||||||
const value = propMatch[2]!.trim();
|
|
||||||
if (key === 'inputs' && value === '') {
|
|
||||||
inInputs = true;
|
|
||||||
} else {
|
|
||||||
inInputs = false;
|
|
||||||
currentStep[key] = value;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step input entries (6 spaces)
|
|
||||||
const inputMatch = line.match(/^\s{6}(\w[\w_-]*):\s*(.+)$/);
|
|
||||||
if (inputMatch && inInputs) {
|
|
||||||
const raw = inputMatch[2]!.trim();
|
|
||||||
stepInputs[inputMatch[1]!] = raw.replace(/^["'](.*)["']$/, '$1');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (currentStep) {
|
|
||||||
if (Object.keys(stepInputs).length > 0) currentStep.inputs = stepInputs;
|
|
||||||
steps.push(currentStep);
|
|
||||||
}
|
|
||||||
if (steps.length > 0) {
|
|
||||||
meta.config = { steps };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { meta, body };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedTasks() {
|
|
||||||
if (!existsSync(SEED_TASKS_DIR)) {
|
|
||||||
console.log('No seed tasks directory found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = readdirSync(SEED_TASKS_DIR, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isDirectory()) continue;
|
|
||||||
|
|
||||||
const taskDir = join(SEED_TASKS_DIR, entry.name);
|
|
||||||
const taskMdPath = join(taskDir, 'TASK.md');
|
|
||||||
if (!existsSync(taskMdPath)) continue;
|
|
||||||
|
|
||||||
const content = readFileSync(taskMdPath, 'utf-8');
|
|
||||||
const { meta, body } = parseFrontmatter(content);
|
|
||||||
|
|
||||||
// Find implementation file
|
|
||||||
let implementation: string | null = null;
|
|
||||||
const lang = meta.language as string | undefined;
|
|
||||||
const implCandidates = [
|
|
||||||
{ file: 'run.sh', lang: 'bash' },
|
|
||||||
{ file: 'index.ts', lang: 'typescript' },
|
|
||||||
{ file: 'run.py', lang: 'python' },
|
|
||||||
{ file: 'index.js', lang: 'javascript' },
|
|
||||||
];
|
|
||||||
for (const c of implCandidates) {
|
|
||||||
const path = join(taskDir, c.file);
|
|
||||||
if (existsSync(path)) {
|
|
||||||
implementation = readFileSync(path, 'utf-8');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = {
|
|
||||||
scope: 'native' as const,
|
|
||||||
userId: null,
|
|
||||||
dirName: entry.name,
|
|
||||||
name: (meta.name as string) || entry.name,
|
|
||||||
description: (meta.description as string) || null,
|
|
||||||
body: body || null,
|
|
||||||
version: (meta.version as number) || 1,
|
|
||||||
mode: (meta.mode as string) || 'agentic',
|
|
||||||
language: (meta.language as string) || null,
|
|
||||||
implementation,
|
|
||||||
args: (meta.args as string[]) || null,
|
|
||||||
trigger: (meta.trigger as TriggerConfig[]) || null,
|
|
||||||
inputs: (meta.inputs as Record<string, unknown>) || null,
|
|
||||||
config: (meta.config as Record<string, unknown>) || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Upsert: check by dirName + scope since unique constraint doesn't work with NULL userId
|
|
||||||
const existing = await db
|
|
||||||
.select({ id: tasks.id })
|
|
||||||
.from(tasks)
|
|
||||||
.where(and(eq(tasks.dirName, values.dirName), eq(tasks.scope, 'native')))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
|
||||||
await db
|
|
||||||
.update(tasks)
|
|
||||||
.set({
|
|
||||||
name: values.name,
|
|
||||||
description: values.description,
|
|
||||||
body: values.body,
|
|
||||||
version: values.version,
|
|
||||||
mode: values.mode,
|
|
||||||
language: values.language,
|
|
||||||
implementation: values.implementation,
|
|
||||||
args: values.args,
|
|
||||||
trigger: values.trigger,
|
|
||||||
inputs: values.inputs,
|
|
||||||
config: values.config,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(tasks.id, existing[0]!.id));
|
|
||||||
} else {
|
|
||||||
await db.insert(tasks).values(values);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Seeded: ${entry.name} (${meta.mode}/${meta.language})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Done seeding tasks');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
seedTasks().catch((err) => {
|
|
||||||
console.error('Seed failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -79,6 +79,7 @@ export {
|
|||||||
createTask,
|
createTask,
|
||||||
updateTask,
|
updateTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
|
upsertNativeTask,
|
||||||
} from './queries/tasks';
|
} from './queries/tasks';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -72,3 +72,20 @@ export async function updateTask(id: number, data: Partial<TaskInsert>) {
|
|||||||
export async function deleteTask(id: number) {
|
export async function deleteTask(id: number) {
|
||||||
await db.delete(tasks).where(eq(tasks.id, id));
|
await db.delete(tasks).where(eq(tasks.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function upsertNativeTask(data: Omit<TaskInsert, 'scope' | 'userId'>) {
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: tasks.id })
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.dirName, data.dirName), eq(tasks.scope, 'native')))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(tasks)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(tasks.id, existing[0]!.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(tasks).values({ ...data, scope: 'native', userId: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mkdirSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { DATA_PATH } from './data-path';
|
import { DATA_PATH } from './data-path';
|
||||||
import { syncMarketplaceTools } from './sync-marketplace';
|
import { syncMarketplaceTools, syncMarketplaceTasks } from './sync-marketplace';
|
||||||
import { ensureToolLoader } from './ensure-tool-loader';
|
import { ensureToolLoader } from './ensure-tool-loader';
|
||||||
// Queue is now owned by the sidecar process
|
// Queue is now owned by the sidecar process
|
||||||
import { startDiscordBotIfConfigured } from './channels/discord/bot';
|
import { startDiscordBotIfConfigured } from './channels/discord/bot';
|
||||||
@@ -68,6 +68,7 @@ async function installPi(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await syncMarketplaceTools();
|
await syncMarketplaceTools();
|
||||||
|
await syncMarketplaceTasks();
|
||||||
ensureToolLoader();
|
ensureToolLoader();
|
||||||
// Queue is initialized by the sidecar process
|
// Queue is initialized by the sidecar process
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { DATA_PATH } from './data-path';
|
import { DATA_PATH } from './data-path';
|
||||||
import { parseSeedVersion } from './sync-version';
|
import { parseSeedVersion } from './sync-version';
|
||||||
|
import { upsertNativeTask } from 'officerdb';
|
||||||
|
|
||||||
const MARKETPLACE_URL = process.env.MARKETPLACE_URL ?? 'https://marketplace.officer.dev';
|
const MARKETPLACE_URL = process.env.MARKETPLACE_URL ?? 'https://marketplace.officer.dev';
|
||||||
const GLOBAL_TOOLS_DIR = join(DATA_PATH, 'tools');
|
const GLOBAL_TOOLS_DIR = join(DATA_PATH, 'tools');
|
||||||
|
|
||||||
|
// ── Tool sync types ──
|
||||||
|
|
||||||
type ToolInput = {
|
type ToolInput = {
|
||||||
type: string;
|
type: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -26,11 +29,35 @@ type MarketplaceTool = {
|
|||||||
implementation: string;
|
implementation: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MarketplaceResponse = {
|
type ToolSyncResponse = {
|
||||||
categories: Array<{ name: string; tools: MarketplaceTool[] }>;
|
categories: Array<{ name: string; tools: MarketplaceTool[] }>;
|
||||||
uncategorized: MarketplaceTool[];
|
uncategorized: MarketplaceTool[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Task sync types ──
|
||||||
|
|
||||||
|
type MarketplaceTask = {
|
||||||
|
dirName: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
body: string;
|
||||||
|
version: number;
|
||||||
|
mode: string;
|
||||||
|
language: string | null;
|
||||||
|
implementation: string | null;
|
||||||
|
args: string[] | null;
|
||||||
|
inputs: Record<string, unknown> | null;
|
||||||
|
trigger: unknown[] | null;
|
||||||
|
config: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskSyncResponse = {
|
||||||
|
categories: Array<{ name: string; tasks: MarketplaceTask[] }>;
|
||||||
|
uncategorized: MarketplaceTask[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tool sync (writes files to disk) ──
|
||||||
|
|
||||||
function buildToolMd(tool: MarketplaceTool): string {
|
function buildToolMd(tool: MarketplaceTool): string {
|
||||||
const lines = ['---'];
|
const lines = ['---'];
|
||||||
lines.push(`name: ${tool.name}`);
|
lines.push(`name: ${tool.name}`);
|
||||||
@@ -72,7 +99,7 @@ export async function syncMarketplaceTools(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as MarketplaceResponse;
|
const data = (await res.json()) as ToolSyncResponse;
|
||||||
|
|
||||||
mkdirSync(GLOBAL_TOOLS_DIR, { recursive: true });
|
mkdirSync(GLOBAL_TOOLS_DIR, { recursive: true });
|
||||||
|
|
||||||
@@ -103,3 +130,46 @@ export async function syncMarketplaceTools(): Promise<void> {
|
|||||||
console.error('[marketplace] Failed to sync tools:', err instanceof Error ? err.message : err);
|
console.error('[marketplace] Failed to sync tools:', err instanceof Error ? err.message : err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Task sync (upserts into officer_db) ──
|
||||||
|
|
||||||
|
export async function syncMarketplaceTasks(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${MARKETPLACE_URL}/api/tasks/native`);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`[marketplace] Failed to fetch tasks: ${res.status} ${res.statusText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as TaskSyncResponse;
|
||||||
|
|
||||||
|
const allTasks: MarketplaceTask[] = [];
|
||||||
|
for (const category of data.categories) {
|
||||||
|
allTasks.push(...category.tasks);
|
||||||
|
}
|
||||||
|
if (data.uncategorized) {
|
||||||
|
allTasks.push(...data.uncategorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of allTasks) {
|
||||||
|
await upsertNativeTask({
|
||||||
|
dirName: task.dirName,
|
||||||
|
name: task.name,
|
||||||
|
description: task.description,
|
||||||
|
body: task.body,
|
||||||
|
version: task.version,
|
||||||
|
mode: task.mode,
|
||||||
|
language: task.language,
|
||||||
|
implementation: task.implementation,
|
||||||
|
args: task.args,
|
||||||
|
inputs: task.inputs,
|
||||||
|
trigger: task.trigger,
|
||||||
|
config: task.config,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[marketplace] Synced task: ${task.dirName} (v${task.version})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[marketplace] Failed to sync tasks:', err instanceof Error ? err.message : err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user