From 46cb89845cf637e7894ff762e3595a1e15873aed Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 3 Jul 2026 13:47:34 +0530 Subject: [PATCH 1/2] Add NASA Images adapter --- cli-manifest.json | 79 ++++++++++++++++++ clis/nasa-images/nasa-images.test.js | 78 +++++++++++++++++ clis/nasa-images/search.js | 120 +++++++++++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 clis/nasa-images/nasa-images.test.js create mode 100644 clis/nasa-images/search.js diff --git a/cli-manifest.json b/cli-manifest.json index bfdb71e..a60f6e4 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -23584,6 +23584,85 @@ "sourceFile": "mubu/search.js", "navigateBefore": "https://mubu.com" }, + { + "site": "nasa-images", + "name": "search", + "description": "Search NASA Images and Video Library media", + "access": "read", + "domain": "images-api.nasa.gov", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search terms, e.g. \"apollo 11\"" + }, + { + "name": "media-type", + "type": "str", + "default": "image", + "required": false, + "help": "image, video, audio, or all", + "choices": [ + "image", + "video", + "audio", + "all" + ] + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Max results (1-100)" + }, + { + "name": "page", + "type": "int", + "default": 1, + "required": false, + "help": "Result page (1-1000)" + }, + { + "name": "year-start", + "type": "int", + "required": false, + "help": "Filter start year, e.g. 1969" + }, + { + "name": "year-end", + "type": "int", + "required": false, + "help": "Filter end year, e.g. 1972" + }, + { + "name": "center", + "type": "str", + "required": false, + "help": "NASA center filter, e.g. JSC" + } + ], + "columns": [ + "rank", + "nasaId", + "title", + "mediaType", + "center", + "dateCreated", + "description", + "keywords", + "previewUrl", + "assetUrl", + "url" + ], + "type": "js", + "modulePath": "nasa-images/search.js", + "sourceFile": "nasa-images/search.js" + }, { "site": "notebooklm", "name": "add-source", diff --git a/clis/nasa-images/nasa-images.test.js b/clis/nasa-images/nasa-images.test.js new file mode 100644 index 0000000..beb79d0 --- /dev/null +++ b/clis/nasa-images/nasa-images.test.js @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ArgumentError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import { getRegistry } from '@agentrhq/webcmd/registry'; +import './search.js'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('nasa-images search', () => { + const cmd = getRegistry().get('nasa-images/search'); + + it('searches NASA Images and maps collection rows', async () => { + const calls = []; + vi.stubGlobal('fetch', vi.fn((url) => { + calls.push(String(url)); + return Promise.resolve(new Response(JSON.stringify({ + collection: { + items: [{ + data: [{ + nasa_id: 'as11-40-5874', + title: 'Apollo 11 Mission image', + media_type: 'image', + center: 'JSC', + date_created: '1969-07-21T00:00:00Z', + description: 'Astronaut Edwin Aldrin poses beside the flag.', + keywords: ['APOLLO 11 FLIGHT', 'MOON'], + }], + links: [{ href: 'https://images-assets.nasa.gov/image/as11-40-5874/as11-40-5874~thumb.jpg' }], + }], + }, + }), { status: 200 })); + })); + + const rows = await cmd.func({ query: 'apollo 11', limit: 1, 'year-start': 1969, 'year-end': 1969, center: 'JSC' }); + expect(calls[0]).toContain('q=apollo+11'); + expect(calls[0]).toContain('media_type=image'); + expect(calls[0]).toContain('year_start=1969'); + expect(rows).toEqual([{ + rank: 1, + nasaId: 'as11-40-5874', + title: 'Apollo 11 Mission image', + mediaType: 'image', + center: 'JSC', + dateCreated: '1969-07-21T00:00:00Z', + description: 'Astronaut Edwin Aldrin poses beside the flag.', + keywords: 'APOLLO 11 FLIGHT, MOON', + previewUrl: 'https://images-assets.nasa.gov/image/as11-40-5874/as11-40-5874~thumb.jpg', + assetUrl: 'https://images-api.nasa.gov/asset/as11-40-5874', + url: 'https://images.nasa.gov/details/as11-40-5874', + }]); + }); + + it('omits media_type when searching all media', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + collection: { items: [{ data: [{ nasa_id: 'demo', title: 'Demo' }] }] }, + }), { status: 200 })))); + + await cmd.func({ query: 'apollo', 'media-type': 'all' }); + expect(String(fetch.mock.calls[0][0])).not.toContain('media_type='); + }); + + it('rejects empty queries', async () => { + await expect(cmd.func({ query: '' })).rejects.toBeInstanceOf(ArgumentError); + }); + + it('rejects out-of-range limits', async () => { + await expect(cmd.func({ query: 'apollo', limit: 101 })).rejects.toBeInstanceOf(ArgumentError); + }); + + it('promotes empty results to EmptyResultError', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + collection: { items: [] }, + }), { status: 200 })))); + await expect(cmd.func({ query: 'not-a-real-query' })).rejects.toBeInstanceOf(EmptyResultError); + }); +}); diff --git a/clis/nasa-images/search.js b/clis/nasa-images/search.js new file mode 100644 index 0000000..eb910a8 --- /dev/null +++ b/clis/nasa-images/search.js @@ -0,0 +1,120 @@ +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import { cli, Strategy } from '@agentrhq/webcmd/registry'; + +const SEARCH_URL = 'https://images-api.nasa.gov/search'; +const UA = 'webcmd-nasa-images-adapter (+https://github.com/agentrhq/webcmd)'; + +function requireQuery(value) { + const query = String(value ?? '').trim(); + if (!query) throw new ArgumentError('nasa-images query is required'); + return query; +} + +function intArg(value, defaultValue, maxValue, label) { + const raw = value == null || value === '' ? defaultValue : value; + const n = Number(raw); + if (!Number.isInteger(n) || n < 1 || n > maxValue) { + throw new ArgumentError(`nasa-images ${label} must be an integer between 1 and ${maxValue}`); + } + return n; +} + +function yearArg(value, label) { + if (value == null || value === '') return null; + const year = Number(value); + if (!Number.isInteger(year) || year < 1900 || year > 2100) { + throw new ArgumentError(`nasa-images ${label} must be a year between 1900 and 2100`); + } + return year; +} + +async function fetchJson(url) { + let resp; + try { + resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } }); + } catch (err) { + throw new CommandExecutionError(`nasa-images search request failed: ${err?.message ?? err}`); + } + if (!resp.ok) { + throw new CommandExecutionError(`nasa-images search returned HTTP ${resp.status}`); + } + try { + return await resp.json(); + } catch (err) { + throw new CommandExecutionError(`nasa-images search returned malformed JSON: ${err?.message ?? err}`); + } +} + +function firstData(item) { + return Array.isArray(item?.data) && item.data.length ? item.data[0] : {}; +} + +function firstPreview(item) { + const link = Array.isArray(item?.links) ? item.links.find((l) => l?.href) : null; + return String(link?.href ?? '').trim(); +} + +function join(values, max = 5) { + return Array.isArray(values) ? values.slice(0, max).filter(Boolean).join(', ') : ''; +} + +cli({ + site: 'nasa-images', + name: 'search', + access: 'read', + description: 'Search NASA Images and Video Library media', + domain: 'images-api.nasa.gov', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'query', positional: true, required: true, help: 'Search terms, e.g. "apollo 11"' }, + { name: 'media-type', default: 'image', choices: ['image', 'video', 'audio', 'all'], help: 'image, video, audio, or all' }, + { name: 'limit', type: 'int', default: 20, help: 'Max results (1-100)' }, + { name: 'page', type: 'int', default: 1, help: 'Result page (1-1000)' }, + { name: 'year-start', type: 'int', help: 'Filter start year, e.g. 1969' }, + { name: 'year-end', type: 'int', help: 'Filter end year, e.g. 1972' }, + { name: 'center', help: 'NASA center filter, e.g. JSC' }, + ], + columns: ['rank', 'nasaId', 'title', 'mediaType', 'center', 'dateCreated', 'description', 'keywords', 'previewUrl', 'assetUrl', 'url'], + func: async (args) => { + const query = requireQuery(args.query); + const limit = intArg(args.limit, 20, 100, 'limit'); + const page = intArg(args.page, 1, 1000, 'page'); + const yearStart = yearArg(args['year-start'], 'year-start'); + const yearEnd = yearArg(args['year-end'], 'year-end'); + const mediaType = String(args['media-type'] ?? 'image').trim(); + + const url = new URL(SEARCH_URL); + url.searchParams.set('q', query); + url.searchParams.set('page_size', String(limit)); + url.searchParams.set('page', String(page)); + if (mediaType !== 'all') url.searchParams.set('media_type', mediaType); + if (yearStart != null) url.searchParams.set('year_start', String(yearStart)); + if (yearEnd != null) url.searchParams.set('year_end', String(yearEnd)); + if (String(args.center ?? '').trim()) url.searchParams.set('center', String(args.center).trim()); + + const body = await fetchJson(url); + const items = Array.isArray(body?.collection?.items) ? body.collection.items : []; + if (!items.length) { + throw new EmptyResultError('nasa-images search', `No NASA media matched "${query}".`); + } + + return items.slice(0, limit).map((item, i) => { + const data = firstData(item); + const nasaId = String(data.nasa_id ?? '').trim(); + return { + rank: (page - 1) * limit + i + 1, + nasaId, + title: String(data.title ?? '').trim(), + mediaType: String(data.media_type ?? '').trim(), + center: String(data.center ?? '').trim(), + dateCreated: String(data.date_created ?? '').trim(), + description: String(data.description ?? '').trim(), + keywords: join(data.keywords), + previewUrl: firstPreview(item), + assetUrl: nasaId ? `https://images-api.nasa.gov/asset/${encodeURIComponent(nasaId)}` : '', + url: nasaId ? `https://images.nasa.gov/details/${encodeURIComponent(nasaId)}` : '', + }; + }); + }, +}); From 4e261570b437a6b6d914113757b0479c38ffe1e6 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 3 Jul 2026 14:02:54 +0530 Subject: [PATCH 2/2] Add NASA Images asset detail commands --- cli-manifest.json | 78 ++++++++++++++++++++++++++ clis/nasa-images/asset.js | 57 +++++++++++++++++++ clis/nasa-images/captions.js | 27 +++++++++ clis/nasa-images/metadata.js | 27 +++++++++ clis/nasa-images/nasa-images.test.js | 84 ++++++++++++++++++++++++++++ clis/nasa-images/search.js | 66 ++-------------------- clis/nasa-images/utils.js | 71 +++++++++++++++++++++++ 7 files changed, 349 insertions(+), 61 deletions(-) create mode 100644 clis/nasa-images/asset.js create mode 100644 clis/nasa-images/captions.js create mode 100644 clis/nasa-images/metadata.js create mode 100644 clis/nasa-images/utils.js diff --git a/cli-manifest.json b/cli-manifest.json index a60f6e4..b33cf7a 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -23584,6 +23584,84 @@ "sourceFile": "mubu/search.js", "navigateBefore": "https://mubu.com" }, + { + "site": "nasa-images", + "name": "asset", + "description": "List downloadable asset files for a NASA media item", + "access": "read", + "domain": "images-api.nasa.gov", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "nasaId", + "type": "str", + "required": true, + "positional": true, + "help": "NASA media id, e.g. as11-40-5874" + } + ], + "columns": [ + "rank", + "nasaId", + "variant", + "extension", + "url" + ], + "type": "js", + "modulePath": "nasa-images/asset.js", + "sourceFile": "nasa-images/asset.js" + }, + { + "site": "nasa-images", + "name": "captions", + "description": "Get the caption file URL for a NASA video item", + "access": "read", + "domain": "images-api.nasa.gov", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "nasaId", + "type": "str", + "required": true, + "positional": true, + "help": "NASA video id, e.g. 172_ISS-Slosh" + } + ], + "columns": [ + "nasaId", + "captionsUrl" + ], + "type": "js", + "modulePath": "nasa-images/captions.js", + "sourceFile": "nasa-images/captions.js" + }, + { + "site": "nasa-images", + "name": "metadata", + "description": "Get the metadata JSON URL for a NASA media item", + "access": "read", + "domain": "images-api.nasa.gov", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "nasaId", + "type": "str", + "required": true, + "positional": true, + "help": "NASA media id, e.g. as11-40-5874" + } + ], + "columns": [ + "nasaId", + "metadataUrl" + ], + "type": "js", + "modulePath": "nasa-images/metadata.js", + "sourceFile": "nasa-images/metadata.js" + }, { "site": "nasa-images", "name": "search", diff --git a/clis/nasa-images/asset.js b/clis/nasa-images/asset.js new file mode 100644 index 0000000..3b1c3b0 --- /dev/null +++ b/clis/nasa-images/asset.js @@ -0,0 +1,57 @@ +import { EmptyResultError } from '@agentrhq/webcmd/errors'; +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { API_BASE, collectionItems, fetchJson, requireNasaId } from './utils.js'; + +function fileNameFromHref(href) { + try { + return decodeURIComponent(new URL(href).pathname.split('/').pop() ?? ''); + } catch { + return decodeURIComponent(String(href).split('/').pop() ?? ''); + } +} + +function fileVariant(href) { + const name = fileNameFromHref(href); + if (/metadata\.json$/i.test(name)) return 'metadata'; + const match = name.match(/~([^./]+)(?:\.[^.]+)?$/); + return match ? match[1] : ''; +} + +function fileExtension(href) { + const match = fileNameFromHref(href).match(/\.([A-Za-z0-9]+)$/); + return match ? match[1].toLowerCase() : ''; +} + +cli({ + site: 'nasa-images', + name: 'asset', + access: 'read', + description: 'List downloadable asset files for a NASA media item', + domain: 'images-api.nasa.gov', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'nasaId', positional: true, required: true, help: 'NASA media id, e.g. as11-40-5874' }, + ], + columns: ['rank', 'nasaId', 'variant', 'extension', 'url'], + func: async (args) => { + const nasaId = requireNasaId(args.nasaId); + const url = new URL(`${API_BASE}/asset/${encodeURIComponent(nasaId)}`); + const body = await fetchJson(url, 'nasa-images asset', { emptyOn404: true }); + const items = collectionItems(body).filter((item) => String(item?.href ?? '').trim()); + if (!items.length) { + throw new EmptyResultError('nasa-images asset', `No asset files found for "${nasaId}".`); + } + + return items.map((item, i) => { + const href = String(item.href).trim(); + return { + rank: i + 1, + nasaId, + variant: fileVariant(href), + extension: fileExtension(href), + url: href, + }; + }); + }, +}); diff --git a/clis/nasa-images/captions.js b/clis/nasa-images/captions.js new file mode 100644 index 0000000..1c1c5b4 --- /dev/null +++ b/clis/nasa-images/captions.js @@ -0,0 +1,27 @@ +import { EmptyResultError } from '@agentrhq/webcmd/errors'; +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { API_BASE, fetchJson, requireNasaId } from './utils.js'; + +cli({ + site: 'nasa-images', + name: 'captions', + access: 'read', + description: 'Get the caption file URL for a NASA video item', + domain: 'images-api.nasa.gov', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'nasaId', positional: true, required: true, help: 'NASA video id, e.g. 172_ISS-Slosh' }, + ], + columns: ['nasaId', 'captionsUrl'], + func: async (args) => { + const nasaId = requireNasaId(args.nasaId); + const url = new URL(`${API_BASE}/captions/${encodeURIComponent(nasaId)}`); + const body = await fetchJson(url, 'nasa-images captions', { emptyOn404: true }); + const captionsUrl = String(body?.location ?? '').trim(); + if (!captionsUrl) { + throw new EmptyResultError('nasa-images captions', `No captions URL found for "${nasaId}".`); + } + return [{ nasaId, captionsUrl }]; + }, +}); diff --git a/clis/nasa-images/metadata.js b/clis/nasa-images/metadata.js new file mode 100644 index 0000000..14e1b44 --- /dev/null +++ b/clis/nasa-images/metadata.js @@ -0,0 +1,27 @@ +import { EmptyResultError } from '@agentrhq/webcmd/errors'; +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { API_BASE, fetchJson, requireNasaId } from './utils.js'; + +cli({ + site: 'nasa-images', + name: 'metadata', + access: 'read', + description: 'Get the metadata JSON URL for a NASA media item', + domain: 'images-api.nasa.gov', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'nasaId', positional: true, required: true, help: 'NASA media id, e.g. as11-40-5874' }, + ], + columns: ['nasaId', 'metadataUrl'], + func: async (args) => { + const nasaId = requireNasaId(args.nasaId); + const url = new URL(`${API_BASE}/metadata/${encodeURIComponent(nasaId)}`); + const body = await fetchJson(url, 'nasa-images metadata', { emptyOn404: true }); + const metadataUrl = String(body?.location ?? '').trim(); + if (!metadataUrl) { + throw new EmptyResultError('nasa-images metadata', `No metadata URL found for "${nasaId}".`); + } + return [{ nasaId, metadataUrl }]; + }, +}); diff --git a/clis/nasa-images/nasa-images.test.js b/clis/nasa-images/nasa-images.test.js index beb79d0..cf9cfa9 100644 --- a/clis/nasa-images/nasa-images.test.js +++ b/clis/nasa-images/nasa-images.test.js @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ArgumentError, EmptyResultError } from '@agentrhq/webcmd/errors'; import { getRegistry } from '@agentrhq/webcmd/registry'; +import './asset.js'; +import './captions.js'; +import './metadata.js'; import './search.js'; afterEach(() => { @@ -76,3 +79,84 @@ describe('nasa-images search', () => { await expect(cmd.func({ query: 'not-a-real-query' })).rejects.toBeInstanceOf(EmptyResultError); }); }); + +describe('nasa-images asset', () => { + const cmd = getRegistry().get('nasa-images/asset'); + + it('lists asset file URLs', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + collection: { + items: [ + { href: 'http://images-assets.nasa.gov/image/as11-40-5874/as11-40-5874~orig.jpg' }, + { href: 'http://images-assets.nasa.gov/image/as11-40-5874/metadata.json' }, + ], + }, + }), { status: 200 })))); + + await expect(cmd.func({ nasaId: 'as11-40-5874' })).resolves.toEqual([ + { + rank: 1, + nasaId: 'as11-40-5874', + variant: 'orig', + extension: 'jpg', + url: 'http://images-assets.nasa.gov/image/as11-40-5874/as11-40-5874~orig.jpg', + }, + { + rank: 2, + nasaId: 'as11-40-5874', + variant: 'metadata', + extension: 'json', + url: 'http://images-assets.nasa.gov/image/as11-40-5874/metadata.json', + }, + ]); + }); + + it('rejects missing NASA ids', async () => { + await expect(cmd.func({ nasaId: '' })).rejects.toBeInstanceOf(ArgumentError); + }); + + it('treats missing assets as empty results', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('', { status: 404 })))); + await expect(cmd.func({ nasaId: 'missing' })).rejects.toBeInstanceOf(EmptyResultError); + }); +}); + +describe('nasa-images metadata', () => { + const cmd = getRegistry().get('nasa-images/metadata'); + + it('returns the metadata URL', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + location: 'https://images-assets.nasa.gov/image/as11-40-5874/metadata.json', + }), { status: 200 })))); + + await expect(cmd.func({ nasaId: 'as11-40-5874' })).resolves.toEqual([{ + nasaId: 'as11-40-5874', + metadataUrl: 'https://images-assets.nasa.gov/image/as11-40-5874/metadata.json', + }]); + }); + + it('requires the metadata location', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({}), { status: 200 })))); + await expect(cmd.func({ nasaId: 'as11-40-5874' })).rejects.toBeInstanceOf(EmptyResultError); + }); +}); + +describe('nasa-images captions', () => { + const cmd = getRegistry().get('nasa-images/captions'); + + it('returns the captions URL', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + location: 'https://images-assets.nasa.gov/video/172_ISS-Slosh/172_ISS-Slosh.srt', + }), { status: 200 })))); + + await expect(cmd.func({ nasaId: '172_ISS-Slosh' })).resolves.toEqual([{ + nasaId: '172_ISS-Slosh', + captionsUrl: 'https://images-assets.nasa.gov/video/172_ISS-Slosh/172_ISS-Slosh.srt', + }]); + }); + + it('treats missing captions as empty results', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('', { status: 404 })))); + await expect(cmd.func({ nasaId: 'as11-40-5874' })).rejects.toBeInstanceOf(EmptyResultError); + }); +}); diff --git a/clis/nasa-images/search.js b/clis/nasa-images/search.js index eb910a8..fcba83f 100644 --- a/clis/nasa-images/search.js +++ b/clis/nasa-images/search.js @@ -1,62 +1,6 @@ -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import { EmptyResultError } from '@agentrhq/webcmd/errors'; import { cli, Strategy } from '@agentrhq/webcmd/registry'; - -const SEARCH_URL = 'https://images-api.nasa.gov/search'; -const UA = 'webcmd-nasa-images-adapter (+https://github.com/agentrhq/webcmd)'; - -function requireQuery(value) { - const query = String(value ?? '').trim(); - if (!query) throw new ArgumentError('nasa-images query is required'); - return query; -} - -function intArg(value, defaultValue, maxValue, label) { - const raw = value == null || value === '' ? defaultValue : value; - const n = Number(raw); - if (!Number.isInteger(n) || n < 1 || n > maxValue) { - throw new ArgumentError(`nasa-images ${label} must be an integer between 1 and ${maxValue}`); - } - return n; -} - -function yearArg(value, label) { - if (value == null || value === '') return null; - const year = Number(value); - if (!Number.isInteger(year) || year < 1900 || year > 2100) { - throw new ArgumentError(`nasa-images ${label} must be a year between 1900 and 2100`); - } - return year; -} - -async function fetchJson(url) { - let resp; - try { - resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } }); - } catch (err) { - throw new CommandExecutionError(`nasa-images search request failed: ${err?.message ?? err}`); - } - if (!resp.ok) { - throw new CommandExecutionError(`nasa-images search returned HTTP ${resp.status}`); - } - try { - return await resp.json(); - } catch (err) { - throw new CommandExecutionError(`nasa-images search returned malformed JSON: ${err?.message ?? err}`); - } -} - -function firstData(item) { - return Array.isArray(item?.data) && item.data.length ? item.data[0] : {}; -} - -function firstPreview(item) { - const link = Array.isArray(item?.links) ? item.links.find((l) => l?.href) : null; - return String(link?.href ?? '').trim(); -} - -function join(values, max = 5) { - return Array.isArray(values) ? values.slice(0, max).filter(Boolean).join(', ') : ''; -} +import { API_BASE, collectionItems, fetchJson, firstData, firstPreview, intArg, join, requireQuery, yearArg } from './utils.js'; cli({ site: 'nasa-images', @@ -84,7 +28,7 @@ cli({ const yearEnd = yearArg(args['year-end'], 'year-end'); const mediaType = String(args['media-type'] ?? 'image').trim(); - const url = new URL(SEARCH_URL); + const url = new URL(`${API_BASE}/search`); url.searchParams.set('q', query); url.searchParams.set('page_size', String(limit)); url.searchParams.set('page', String(page)); @@ -93,8 +37,8 @@ cli({ if (yearEnd != null) url.searchParams.set('year_end', String(yearEnd)); if (String(args.center ?? '').trim()) url.searchParams.set('center', String(args.center).trim()); - const body = await fetchJson(url); - const items = Array.isArray(body?.collection?.items) ? body.collection.items : []; + const body = await fetchJson(url, 'nasa-images search'); + const items = collectionItems(body); if (!items.length) { throw new EmptyResultError('nasa-images search', `No NASA media matched "${query}".`); } diff --git a/clis/nasa-images/utils.js b/clis/nasa-images/utils.js new file mode 100644 index 0000000..4cf0fce --- /dev/null +++ b/clis/nasa-images/utils.js @@ -0,0 +1,71 @@ +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; + +export const API_BASE = 'https://images-api.nasa.gov'; +const UA = 'webcmd-nasa-images-adapter (+https://github.com/agentrhq/webcmd)'; + +export function requireQuery(value) { + const query = String(value ?? '').trim(); + if (!query) throw new ArgumentError('nasa-images query is required'); + return query; +} + +export function requireNasaId(value) { + const nasaId = String(value ?? '').trim(); + if (!nasaId) throw new ArgumentError('nasa-images nasaId is required'); + return nasaId; +} + +export function intArg(value, defaultValue, maxValue, label) { + const raw = value == null || value === '' ? defaultValue : value; + const n = Number(raw); + if (!Number.isInteger(n) || n < 1 || n > maxValue) { + throw new ArgumentError(`nasa-images ${label} must be an integer between 1 and ${maxValue}`); + } + return n; +} + +export function yearArg(value, label) { + if (value == null || value === '') return null; + const year = Number(value); + if (!Number.isInteger(year) || year < 1900 || year > 2100) { + throw new ArgumentError(`nasa-images ${label} must be a year between 1900 and 2100`); + } + return year; +} + +export async function fetchJson(url, label, { emptyOn404 = false } = {}) { + let resp; + try { + resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } }); + } catch (err) { + throw new CommandExecutionError(`${label} request failed: ${err?.message ?? err}`); + } + if (resp.status === 404 && emptyOn404) { + throw new EmptyResultError(label, `${label} was not found.`); + } + if (!resp.ok) { + throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`); + } + try { + return await resp.json(); + } catch (err) { + throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`); + } +} + +export function collectionItems(body) { + return Array.isArray(body?.collection?.items) ? body.collection.items : []; +} + +export function firstData(item) { + return Array.isArray(item?.data) && item.data.length ? item.data[0] : {}; +} + +export function firstPreview(item) { + const link = Array.isArray(item?.links) ? item.links.find((l) => l?.href) : null; + return String(link?.href ?? '').trim(); +} + +export function join(values, max = 5) { + return Array.isArray(values) ? values.slice(0, max).filter(Boolean).join(', ') : ''; +}