Skip to content

Commit ac15bff

Browse files
Add agent-friendly CLI CRUD workflows (#95)
* Add agent-friendly CLI workflows * Fix CI typecheck and lint
1 parent 8f686b8 commit ac15bff

13 files changed

Lines changed: 844 additions & 92 deletions

File tree

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.3.3/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
33
"vcs": {
44
"enabled": true,
55
"clientKind": "git",

docs/cli-commands.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ These flags are available on all service resource actions:
1717
| `--table` | Output as formatted table with colors and status indicators |
1818
| `--plain` | Output as TSV (tab-separated, no colors, stable for piping) |
1919
| `--quiet` / `-q` | Output only IDs |
20+
| `--no-header` | Hide the table header row |
2021
| `--select` | Cherry-pick fields in JSON mode (comma-separated, e.g. `--select=title,year`) |
2122
| `--yes` / `-y` | Skip confirmation prompts |
23+
| `--dry-run` | Show what would happen without executing |
2224

2325
## Radarr (Movies)
2426

@@ -31,9 +33,12 @@ tsarr radarr <resource> <action> [args]
3133
| `movie` | `list` | | List all movies |
3234
| `movie` | `get` | `<id>` | Get movie by ID |
3335
| `movie` | `search` | `<term>` | Search TMDB for movies |
36+
| `movie` | `add` | `[term] [--tmdb-id <id>] [--quality-profile-id <id>] [--root-folder <path>]` | Add a movie interactively or by TMDB ID |
3437
| `movie` | `delete` | `<id>` | Delete a movie |
3538
| `profile` | `list` | | List quality profiles |
3639
| `profile` | `get` | `<id>` | Get quality profile by ID |
40+
| `tag` | `create` | `<label>` | Create a tag |
41+
| `tag` | `delete` | `<id>` | Delete a tag |
3742
| `tag` | `list` | | List all tags |
3843
| `queue` | `list` | | Show download queue |
3944
| `queue` | `status` | | Queue overview |
@@ -54,11 +59,17 @@ tsarr sonarr <resource> <action> [args]
5459
| `series` | `list` | | List all series |
5560
| `series` | `get` | `<id>` | Get series by ID |
5661
| `series` | `search` | `<term>` | Search for TV series |
62+
| `series` | `add` | `[term] [--tvdb-id <id>] [--quality-profile-id <id>] [--root-folder <path>]` | Add a series interactively or by TVDB ID |
5763
| `series` | `delete` | `<id>` | Delete a series |
58-
| `episode` | `list` | | List all episodes |
64+
| `episode` | `list` | `[--series-id <id>]` | List all episodes or filter by series |
5965
| `episode` | `get` | `<id>` | Get episode by ID |
6066
| `profile` | `list` | | List quality profiles |
67+
| `tag` | `create` | `<label>` | Create a tag |
68+
| `tag` | `delete` | `<id>` | Delete a tag |
6169
| `tag` | `list` | | List all tags |
70+
| `queue` | `list` | `[--series-id <id>]` | Show download queue |
71+
| `queue` | `status` | | Queue overview |
72+
| `history` | `list` | `[--series-id <id>]` | Recent activity |
6273
| `rootfolder` | `list` | | List root folders |
6374
| `system` | `status` | | System information |
6475
| `system` | `health` | | Health check issues |
@@ -79,6 +90,8 @@ tsarr lidarr <resource> <action> [args]
7990
| `album` | `get` | `<id>` | Get album by ID |
8091
| `album` | `search` | `<term>` | Search for albums |
8192
| `profile` | `list` | | List quality profiles |
93+
| `tag` | `create` | `<label>` | Create a tag |
94+
| `tag` | `delete` | `<id>` | Delete a tag |
8295
| `tag` | `list` | | List all tags |
8396
| `rootfolder` | `list` | | List root folders |
8497
| `system` | `status` | | System information |
@@ -100,6 +113,8 @@ tsarr readarr <resource> <action> [args]
100113
| `book` | `get` | `<id>` | Get book by ID |
101114
| `book` | `search` | `<term>` | Search for books |
102115
| `profile` | `list` | | List quality profiles |
116+
| `tag` | `create` | `<label>` | Create a tag |
117+
| `tag` | `delete` | `<id>` | Delete a tag |
103118
| `tag` | `list` | | List all tags |
104119
| `rootfolder` | `list` | | List root folders |
105120
| `system` | `status` | | System information |
@@ -116,9 +131,11 @@ tsarr prowlarr <resource> <action> [args]
116131
| `indexer` | `list` | | List all indexers |
117132
| `indexer` | `get` | `<id>` | Get indexer by ID |
118133
| `indexer` | `delete` | `<id>` | Delete an indexer |
119-
| `search` | `run` | `<query>` | Search across all indexers |
134+
| `search` | `run` | `<term>` or `<query>` | Search across all indexers |
120135
| `app` | `list` | | List connected applications |
121136
| `app` | `get` | `<id>` | Get application by ID |
137+
| `tag` | `create` | `<label>` | Create a tag |
138+
| `tag` | `delete` | `<id>` | Delete a tag |
122139
| `tag` | `list` | | List all tags |
123140
| `system` | `status` | | System information |
124141
| `system` | `health` | | Health check issues |

src/cli/commands/lidarr.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,28 @@ const resources: ResourceDef[] = [
166166
name: 'tag',
167167
description: 'Manage tags',
168168
actions: [
169+
{
170+
name: 'create',
171+
description: 'Create a tag',
172+
args: [{ name: 'label', description: 'Tag label', required: true }],
173+
run: (c: LidarrClient, a) => c.addTag({ label: a.label } as any),
174+
},
175+
{
176+
name: 'delete',
177+
description: 'Delete a tag',
178+
args: [{ name: 'id', description: 'Tag ID', required: true, type: 'number' }],
179+
confirmMessage: 'Are you sure you want to delete this tag?',
180+
run: async (c: LidarrClient, a) => {
181+
const tagResult = await c.getTag(a.id);
182+
if (tagResult?.error) return tagResult;
183+
184+
const tag = (tagResult?.data ?? tagResult) as any;
185+
const deleteResult = await c.deleteTag(a.id);
186+
if (deleteResult?.error) return deleteResult;
187+
188+
return { message: `Deleted tag: ${tag.label} (ID: ${tag.id})` };
189+
},
190+
},
169191
{
170192
name: 'list',
171193
description: 'List all tags',

src/cli/commands/prowlarr.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ProwlarrClient } from '../../clients/prowlarr.js';
2+
import { promptIfMissing } from '../prompt.js';
23
import type { ResourceDef } from './service.js';
34
import { buildServiceCommand } from './service.js';
45

@@ -35,9 +36,13 @@ const resources: ResourceDef[] = [
3536
{
3637
name: 'run',
3738
description: 'Search across indexers',
38-
args: [{ name: 'query', description: 'Search query', required: true }],
39+
args: [
40+
{ name: 'term', description: 'Search term' },
41+
{ name: 'query', description: 'Search query' },
42+
],
3943
columns: ['indexer', 'title', 'size', 'seeders'],
40-
run: (c: ProwlarrClient, a) => c.search(a.query),
44+
run: async (c: ProwlarrClient, a) =>
45+
c.search(await promptIfMissing(a.term ?? a.query, 'Search term:')),
4146
},
4247
],
4348
},
@@ -63,6 +68,28 @@ const resources: ResourceDef[] = [
6368
name: 'tag',
6469
description: 'Manage tags',
6570
actions: [
71+
{
72+
name: 'create',
73+
description: 'Create a tag',
74+
args: [{ name: 'label', description: 'Tag label', required: true }],
75+
run: (c: ProwlarrClient, a) => c.addTag({ label: a.label } as any),
76+
},
77+
{
78+
name: 'delete',
79+
description: 'Delete a tag',
80+
args: [{ name: 'id', description: 'Tag ID', required: true, type: 'number' }],
81+
confirmMessage: 'Are you sure you want to delete this tag?',
82+
run: async (c: ProwlarrClient, a) => {
83+
const tagResult = await c.getTag(a.id);
84+
if (tagResult?.error) return tagResult;
85+
86+
const tag = (tagResult?.data ?? tagResult) as any;
87+
const deleteResult = await c.deleteTag(a.id);
88+
if (deleteResult?.error) return deleteResult;
89+
90+
return { message: `Deleted tag: ${tag.label} (ID: ${tag.id})` };
91+
},
92+
},
6693
{
6794
name: 'list',
6895
description: 'List all tags',

src/cli/commands/radarr.ts

Lines changed: 157 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { RadarrClient } from '../../clients/radarr.js';
2-
import { promptConfirm, promptSelect } from '../prompt.js';
2+
import { promptConfirm, promptIfMissing, promptSelect } from '../prompt.js';
33
import type { ResourceDef } from './service.js';
44
import { buildServiceCommand } from './service.js';
55

@@ -31,52 +31,95 @@ const resources: ResourceDef[] = [
3131
{
3232
name: 'add',
3333
description: 'Search and add a movie',
34-
args: [{ name: 'term', description: 'Search term', required: true }],
34+
args: [
35+
{ name: 'term', description: 'Search term' },
36+
{ name: 'tmdb-id', description: 'TMDB ID', type: 'number' },
37+
{ name: 'quality-profile-id', description: 'Quality profile ID', type: 'number' },
38+
{ name: 'root-folder', description: 'Root folder path' },
39+
{ name: 'monitored', description: 'Set monitored (true/false)' },
40+
],
3541
run: async (c: RadarrClient, a) => {
36-
const searchResult = await c.searchMovies(a.term);
37-
const results = searchResult?.data ?? searchResult;
38-
if (!Array.isArray(results) || results.length === 0) {
39-
throw new Error('No movies found.');
40-
}
41-
const movieId = await promptSelect(
42-
'Select a movie:',
43-
results.map((m: any) => ({ label: `${m.title} (${m.year})`, value: String(m.tmdbId) }))
44-
);
45-
const movie = results.find((m: any) => String(m.tmdbId) === movieId);
46-
if (!movie) {
47-
throw new Error('Selected movie was not found in the search results.');
42+
let movie: any;
43+
44+
if (a['tmdb-id'] !== undefined) {
45+
const lookupResult = await c.lookupMovieByTmdbId(a['tmdb-id']);
46+
const lookup = unwrapData<any[] | any>(lookupResult);
47+
const matches = Array.isArray(lookup) ? lookup : [lookup];
48+
movie = matches.find((m: any) => m?.tmdbId === a['tmdb-id']) ?? matches[0];
49+
50+
if (!movie) {
51+
throw new Error(`No movie found for TMDB ID ${a['tmdb-id']}.`);
52+
}
53+
} else {
54+
const term = await promptIfMissing(a.term, 'Search term:');
55+
const searchResult = await c.searchMovies(term);
56+
const results = unwrapData<any[]>(searchResult);
57+
if (!Array.isArray(results) || results.length === 0) {
58+
throw new Error('No movies found.');
59+
}
60+
61+
const movieId = await promptSelect(
62+
'Select a movie:',
63+
results.map((m: any) => ({
64+
label: `${m.title} (${m.year})`,
65+
value: String(m.tmdbId),
66+
}))
67+
);
68+
movie = results.find((m: any) => String(m.tmdbId) === movieId);
69+
if (!movie) {
70+
throw new Error('Selected movie was not found in the search results.');
71+
}
4872
}
4973

5074
const profilesResult = await c.getQualityProfiles();
51-
const profiles = profilesResult?.data ?? profilesResult;
75+
const profiles = unwrapData<any[]>(profilesResult);
5276
if (!Array.isArray(profiles) || profiles.length === 0) {
5377
throw new Error('No quality profiles found. Configure one in Radarr first.');
5478
}
55-
const profileId = await promptSelect(
56-
'Select quality profile:',
57-
profiles.map((p: any) => ({ label: p.name, value: String(p.id) }))
58-
);
79+
const profileId =
80+
a['quality-profile-id'] !== undefined
81+
? resolveQualityProfileId(profiles, a['quality-profile-id'])
82+
: Number(
83+
await promptSelect(
84+
'Select quality profile:',
85+
profiles.map((p: any) => ({ label: p.name, value: String(p.id) }))
86+
)
87+
);
5988

6089
const foldersResult = await c.getRootFolders();
61-
const folders = foldersResult?.data ?? foldersResult;
90+
const folders = unwrapData<any[]>(foldersResult);
6291
if (!Array.isArray(folders) || folders.length === 0) {
6392
throw new Error('No root folders found. Configure one in Radarr first.');
6493
}
65-
const rootFolderPath = await promptSelect(
66-
'Select root folder:',
67-
folders.map((f: any) => ({ label: f.path, value: f.path }))
68-
);
94+
const rootFolderPath =
95+
a['root-folder'] !== undefined
96+
? resolveRootFolderPath(folders, a['root-folder'])
97+
: await promptSelect(
98+
'Select root folder:',
99+
folders.map((f: any) => ({ label: f.path, value: f.path }))
100+
);
69101

70102
const confirmed = await promptConfirm(`Add "${movie.title} (${movie.year})"?`, !!a.yes);
71103
if (!confirmed) throw new Error('Cancelled.');
72104

73-
return c.addMovie({
105+
const addResult = await c.addMovie({
74106
...movie,
75-
qualityProfileId: Number(profileId),
107+
qualityProfileId: profileId,
76108
rootFolderPath,
77-
monitored: true,
109+
monitored: parseBooleanArg(a.monitored, true),
78110
addOptions: { searchForMovie: true },
79111
});
112+
113+
if (addResult?.error && getApiStatus(addResult) === 400) {
114+
const existingMovie = await findMovieByTmdbId(c, movie.tmdbId);
115+
if (existingMovie) {
116+
throw new Error(
117+
`${existingMovie.title} is already in your library (ID: ${existingMovie.id})`
118+
);
119+
}
120+
}
121+
122+
return addResult;
80123
},
81124
},
82125
{
@@ -117,9 +160,30 @@ const resources: ResourceDef[] = [
117160
{
118161
name: 'delete',
119162
description: 'Delete a movie',
120-
args: [{ name: 'id', description: 'Movie ID', required: true, type: 'number' }],
163+
args: [
164+
{ name: 'id', description: 'Movie ID', required: true, type: 'number' },
165+
{ name: 'delete-files', description: 'Delete movie files', type: 'boolean' },
166+
{
167+
name: 'add-import-exclusion',
168+
description: 'Add import exclusion after delete',
169+
type: 'boolean',
170+
},
171+
],
121172
confirmMessage: 'Are you sure you want to delete this movie?',
122-
run: (c: RadarrClient, a) => c.deleteMovie(a.id),
173+
run: async (c: RadarrClient, a) => {
174+
const movieResult = await c.getMovie(a.id);
175+
if (movieResult?.error) return movieResult;
176+
177+
const movie = unwrapData<any>(movieResult);
178+
const deleteResult = await c.deleteMovie(a.id, {
179+
deleteFiles: a['delete-files'],
180+
addImportExclusion: a['add-import-exclusion'],
181+
});
182+
183+
if (deleteResult?.error) return deleteResult;
184+
185+
return { message: `Deleted: ${movie.title} (ID: ${movie.id})` };
186+
},
123187
},
124188
],
125189
},
@@ -145,6 +209,28 @@ const resources: ResourceDef[] = [
145209
name: 'tag',
146210
description: 'Manage tags',
147211
actions: [
212+
{
213+
name: 'create',
214+
description: 'Create a tag',
215+
args: [{ name: 'label', description: 'Tag label', required: true }],
216+
run: (c: RadarrClient, a) => c.addTag({ label: a.label } as any),
217+
},
218+
{
219+
name: 'delete',
220+
description: 'Delete a tag',
221+
args: [{ name: 'id', description: 'Tag ID', required: true, type: 'number' }],
222+
confirmMessage: 'Are you sure you want to delete this tag?',
223+
run: async (c: RadarrClient, a) => {
224+
const tagResult = await c.getTag(a.id);
225+
if (tagResult?.error) return tagResult;
226+
227+
const tag = unwrapData<any>(tagResult);
228+
const deleteResult = await c.deleteTag(a.id);
229+
if (deleteResult?.error) return deleteResult;
230+
231+
return { message: `Deleted tag: ${tag.label} (ID: ${tag.id})` };
232+
},
233+
},
148234
{
149235
name: 'list',
150236
description: 'List all tags',
@@ -231,3 +317,45 @@ export const radarr = buildServiceCommand(
231317
config => new RadarrClient(config),
232318
resources
233319
);
320+
321+
function unwrapData<T>(result: any): T {
322+
return (result?.data ?? result) as T;
323+
}
324+
325+
function parseBooleanArg(value: unknown, fallback: boolean): boolean {
326+
if (value === undefined) return fallback;
327+
if (typeof value === 'boolean') return value;
328+
if (typeof value === 'string') {
329+
const normalized = value.trim().toLowerCase();
330+
if (normalized === 'true') return true;
331+
if (normalized === 'false') return false;
332+
}
333+
return Boolean(value);
334+
}
335+
336+
function resolveQualityProfileId(profiles: any[], profileId: number): number {
337+
const profile = profiles.find((item: any) => item?.id === profileId);
338+
if (!profile) {
339+
throw new Error(`Quality profile ${profileId} was not found.`);
340+
}
341+
return profileId;
342+
}
343+
344+
function resolveRootFolderPath(folders: any[], rootFolderPath: string): string {
345+
const folder = folders.find((item: any) => item?.path === rootFolderPath);
346+
if (!folder) {
347+
throw new Error(`Root folder "${rootFolderPath}" was not found.`);
348+
}
349+
return rootFolderPath;
350+
}
351+
352+
async function findMovieByTmdbId(client: RadarrClient, tmdbId: number | undefined) {
353+
if (tmdbId === undefined) return undefined;
354+
355+
const movies = unwrapData<any[]>(await client.getMovies());
356+
return movies.find((movie: any) => movie?.tmdbId === tmdbId);
357+
}
358+
359+
function getApiStatus(result: any): number | undefined {
360+
return result?.error?.status ?? result?.response?.status;
361+
}

0 commit comments

Comments
 (0)