@orangemust/unsplash-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,33 @@
1
+ # unsplash-cli
2
+
3
+ Agent notes for `@orangemust/unsplash-cli`.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ unsplash auth status --json
9
+ unsplash auth set --stdin # pbpaste | unsplash auth set --stdin
10
+ unsplash auth clear
11
+ unsplash search "<query>" --json
12
+ unsplash search "<query>" --download 2 --out-dir <dir> --json
13
+ unsplash download <id|url> --out <file> --json
14
+ ```
15
+
16
+ ## Rules
17
+
18
+ - Never print, echo or log the Access Key.
19
+ - Key lives in macOS Keychain (service `unsplash-cli`); `UNSPLASH_ACCESS_KEY` is an env override for CI only.
20
+ - Do not create `.env` files for this project.
21
+ - `--json` is the agent-facing output; human output is for terminals.
22
+ - Downloads write a `<photo-id>.json` sidecar and trigger Unsplash download tracking automatically.
23
+ - Keep attribution (`Photo by <name> on Unsplash`) when an image is published.
24
+ - 401/403/429 are not retried automatically; report the error.
25
+ - Tests: `npm test` (local mock API, no Keychain/network).
26
+
27
+ ## Build
28
+
29
+ ```bash
30
+ npm run typecheck
31
+ npm test
32
+ npm run build
33
+ ```
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mack-peng
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,126 @@
1
- # Temporary Holding Version
1
+ # @orangemust/unsplash-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Search and download [Unsplash](https://unsplash.com) photos from the command line.
4
+
5
+ - Search with orientation / color / order filters
6
+ - Download the original or a specific size
7
+ - Writes a `<photo-id>.json` sidecar with photographer, source page and license
8
+ - Tracks downloads as required by the Unsplash API guidelines
9
+ - **Access Key stored in macOS Keychain** — never in `.env`, project files or shell history
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ # from this directory
15
+ npm install
16
+ npm run build
17
+ npm link
18
+
19
+ # or after publishing
20
+ npm install -g @orangemust/unsplash-cli
21
+ ```
22
+
23
+ > `unsplash-cli` on npm is an unrelated, unmaintained 2015 package. This project uses the scoped name `@orangemust/unsplash-cli`; the command is `unsplash` (alias `unsplash-cli`).
24
+
25
+ ## Authentication
26
+
27
+ Get an Access Key from <https://unsplash.com/oauth/applications> (New application → Access Key, not the Secret Key).
28
+
29
+ Store it in the macOS Keychain:
30
+
31
+ ```bash
32
+ unsplash auth set
33
+ # or paste from clipboard without putting the key in shell history
34
+ pbpaste | unsplash auth set --stdin
35
+ ```
36
+
37
+ Check status without printing the key:
38
+
39
+ ```bash
40
+ unsplash auth status
41
+ # Configured: yes
42
+ # Source: macOS Keychain (service: unsplash-cli)
43
+ ```
44
+
45
+ Remove it:
46
+
47
+ ```bash
48
+ unsplash auth clear
49
+ ```
50
+
51
+ The key is stored as a generic password with service `unsplash-cli` and account `default`. It is read with the macOS `security` command; the first read may show a Keychain access prompt — choose **Always Allow** if you do not want to see it again.
52
+
53
+ For CI or non-macOS environments, set `UNSPLASH_ACCESS_KEY`; it takes precedence over Keychain storage.
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ # Search
59
+ unsplash search "minimal desk" --per-page 6
60
+ unsplash search "minimal desk" --orientation portrait --color white --json
61
+
62
+ # Download one photo by id or Unsplash URL
63
+ unsplash download abc123XYZ --out ./assets/desk.jpg
64
+ unsplash download https://unsplash.com/photos/some-slug-abc123XYZ --out-dir ./assets
65
+
66
+ # Search and download the first two results
67
+ unsplash search "minimal desk" --download 2 --out-dir ./assets/refs
68
+ ```
69
+
70
+ ### Options
71
+
72
+ `search`:
73
+
74
+ - `--per-page N` — 1–30, default 6
75
+ - `--page N` — default 1
76
+ - `--orientation landscape|portrait|squarish`
77
+ - `--color black_and_white|black|white|yellow|orange|red|purple|magenta|green|teal|blue`
78
+ - `--order-by relevant|latest`
79
+ - `--download N` — requires `--out-dir`
80
+ - `--out-dir DIR`
81
+ - `--size raw|full|regular|small|thumb` — default `full`
82
+ - `--json`
83
+
84
+ `download`:
85
+
86
+ - `--out FILE` or `--out-dir DIR`
87
+ - `--size raw|full|regular|small|thumb` — default `full`
88
+ - `--json`
89
+
90
+ ## Output and compliance
91
+
92
+ Human output lists the photo id, dimensions, description, photographer credit and source page. `--json` returns a compact object for agents.
93
+
94
+ Each download saves the image plus a sidecar:
95
+
96
+ ```json
97
+ {
98
+ "provider": "unsplash",
99
+ "id": "abc123XYZ",
100
+ "photographer": { "name": "Jane Doe", "profile_url": "https://unsplash.com/@jane?utm_source=unsplash-cli&utm_medium=referral" },
101
+ "photo_page": "https://unsplash.com/photos/abc123XYZ?utm_source=unsplash-cli&utm_medium=referral",
102
+ "attribution": "Photo by Jane Doe on Unsplash (...)",
103
+ "download_tracked": true,
104
+ "license": "https://unsplash.com/license"
105
+ }
106
+ ```
107
+
108
+ When publishing an image, keep the attribution and link. The Unsplash License allows free commercial use, but does not allow selling unmodified copies or building a competing image service.
109
+
110
+ The CLI calls Unsplash's `download_location` endpoint after saving an image. If tracking fails it warns but keeps the file. API errors (401/403/429) are reported without retrying.
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ npm install
116
+ npm run typecheck
117
+ npm test
118
+ npm run build
119
+ node bin/unsplash.js --help
120
+ ```
121
+
122
+ Tests use a local mock API (`UNSPLASH_API_BASE`) and never touch the real Keychain or network.
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ const { main } = require('../dist/index');
3
+
4
+ main().catch((error) => {
5
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
6
+ process.exit(1);
7
+ });
package/dist/api.js ADDED
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UnsplashApi = exports.ApiError = void 0;
4
+ class ApiError extends Error {
5
+ status;
6
+ constructor(status, message) {
7
+ super(message);
8
+ this.status = status;
9
+ this.name = 'ApiError';
10
+ }
11
+ }
12
+ exports.ApiError = ApiError;
13
+ class UnsplashApi {
14
+ key;
15
+ base;
16
+ lastRate = { limit: null, remaining: null };
17
+ constructor(key, base = process.env.UNSPLASH_API_BASE || 'https://api.unsplash.com') {
18
+ this.key = key;
19
+ this.base = base.replace(/\/+$/, '');
20
+ }
21
+ headers() {
22
+ return {
23
+ Authorization: `Client-ID ${this.key}`,
24
+ 'Accept-Version': 'v1',
25
+ };
26
+ }
27
+ updateRate(response) {
28
+ this.lastRate = {
29
+ limit: response.headers.get('x-ratelimit-limit') ?? this.lastRate.limit,
30
+ remaining: response.headers.get('x-ratelimit-remaining') ?? this.lastRate.remaining,
31
+ };
32
+ }
33
+ async request(path, params = {}) {
34
+ const url = new URL(this.base + path);
35
+ for (const [name, value] of Object.entries(params)) {
36
+ if (value !== undefined && value !== '')
37
+ url.searchParams.set(name, String(value));
38
+ }
39
+ const response = await fetch(url, { headers: this.headers() });
40
+ this.updateRate(response);
41
+ if (!response.ok)
42
+ throw await toApiError(response);
43
+ return (await response.json());
44
+ }
45
+ search(query, options = {}) {
46
+ return this.request('/search/photos', {
47
+ query,
48
+ per_page: options.perPage,
49
+ page: options.page,
50
+ orientation: options.orientation,
51
+ color: options.color,
52
+ order_by: options.orderBy,
53
+ });
54
+ }
55
+ photo(id) {
56
+ return this.request(`/photos/${encodeURIComponent(id)}`);
57
+ }
58
+ random() {
59
+ return this.request('/photos/random');
60
+ }
61
+ async track(downloadLocation) {
62
+ if (!downloadLocation)
63
+ return false;
64
+ try {
65
+ const response = await fetch(downloadLocation, { headers: this.headers() });
66
+ return response.ok;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ async fetchImage(url) {
73
+ const response = await fetch(url);
74
+ if (!response.ok)
75
+ throw new Error(`Image download failed (HTTP ${response.status}).`);
76
+ return {
77
+ buffer: Buffer.from(await response.arrayBuffer()),
78
+ contentType: response.headers.get('content-type') ?? '',
79
+ };
80
+ }
81
+ }
82
+ exports.UnsplashApi = UnsplashApi;
83
+ async function toApiError(response) {
84
+ let detail = '';
85
+ try {
86
+ const body = (await response.json());
87
+ detail = body.errors?.join('; ') || body.error || '';
88
+ }
89
+ catch {
90
+ // ignore non-JSON error bodies
91
+ }
92
+ if (response.status === 401) {
93
+ return new ApiError(401, 'Invalid Unsplash Access Key (401). Run `unsplash auth set` to update it.');
94
+ }
95
+ if (response.status === 403 || response.status === 429) {
96
+ return new ApiError(response.status, `Unsplash rate limit reached (${response.status}). Try again later.`);
97
+ }
98
+ return new ApiError(response.status, `Unsplash API error ${response.status}${detail ? `: ${detail}` : ''}.`);
99
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.keychainSupported = keychainSupported;
4
+ exports.keychainStatus = keychainStatus;
5
+ exports.readKeychainKey = readKeychainKey;
6
+ exports.writeKeychainKey = writeKeychainKey;
7
+ exports.deleteKeychainKey = deleteKeychainKey;
8
+ exports.envKey = envKey;
9
+ exports.resolveKey = resolveKey;
10
+ exports.readSecret = readSecret;
11
+ const node_child_process_1 = require("node:child_process");
12
+ const node_os_1 = require("node:os");
13
+ const node_readline_1 = require("node:readline");
14
+ const SERVICE = 'unsplash-cli';
15
+ const ACCOUNT = 'default';
16
+ function keychainSupported() {
17
+ return (0, node_os_1.platform)() === 'darwin';
18
+ }
19
+ function keychainStatus() {
20
+ if (!keychainSupported())
21
+ return 'unsupported';
22
+ try {
23
+ (0, node_child_process_1.execFileSync)('security', ['find-generic-password', '-s', SERVICE, '-a', ACCOUNT], {
24
+ stdio: 'ignore',
25
+ });
26
+ return 'configured';
27
+ }
28
+ catch {
29
+ return 'missing';
30
+ }
31
+ }
32
+ function readKeychainKey() {
33
+ if (!keychainSupported())
34
+ return null;
35
+ try {
36
+ const key = (0, node_child_process_1.execFileSync)('security', ['find-generic-password', '-s', SERVICE, '-a', ACCOUNT, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
37
+ return key || null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function writeKeychainKey(key) {
44
+ if (!keychainSupported()) {
45
+ throw new Error('macOS Keychain is not available on this platform. Use UNSPLASH_ACCESS_KEY instead.');
46
+ }
47
+ (0, node_child_process_1.execFileSync)('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', ACCOUNT, '-w', key], { stdio: 'ignore' });
48
+ }
49
+ function deleteKeychainKey() {
50
+ if (!keychainSupported())
51
+ return false;
52
+ try {
53
+ (0, node_child_process_1.execFileSync)('security', ['delete-generic-password', '-s', SERVICE, '-a', ACCOUNT], {
54
+ stdio: 'ignore',
55
+ });
56
+ return true;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ function envKey() {
63
+ const key = process.env.UNSPLASH_ACCESS_KEY?.trim();
64
+ return key || null;
65
+ }
66
+ function resolveKey() {
67
+ const env = envKey();
68
+ if (env)
69
+ return { key: env, source: 'env' };
70
+ const keychain = readKeychainKey();
71
+ if (keychain)
72
+ return { key: keychain, source: 'keychain' };
73
+ throw new Error('Unsplash Access Key is not configured. Run `unsplash auth set`.');
74
+ }
75
+ async function readSecret(label, forceStdin = false) {
76
+ if (forceStdin || !process.stdin.isTTY) {
77
+ if (forceStdin && process.stdin.isTTY) {
78
+ throw new Error('--stdin requires piped input. Pipe the key or omit --stdin.');
79
+ }
80
+ const chunks = [];
81
+ for await (const chunk of process.stdin)
82
+ chunks.push(Buffer.from(chunk));
83
+ return Buffer.concat(chunks).toString('utf8').trim();
84
+ }
85
+ process.stdout.write(label);
86
+ return new Promise((resolve, reject) => {
87
+ const rl = (0, node_readline_1.createInterface)({ input: process.stdin, output: process.stdout, terminal: true });
88
+ rl._writeToOutput = () => { };
89
+ rl.question('', (value) => {
90
+ rl.close();
91
+ process.stdout.write('\n');
92
+ resolve(value.trim());
93
+ });
94
+ rl.on('SIGINT', () => {
95
+ rl.close();
96
+ process.stdout.write('\n');
97
+ reject(new Error('Cancelled.'));
98
+ });
99
+ });
100
+ }
package/dist/index.js ADDED
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.main = main;
4
+ const node_fs_1 = require("node:fs");
5
+ const node_path_1 = require("node:path");
6
+ const node_util_1 = require("node:util");
7
+ const api_1 = require("./api");
8
+ const auth_1 = require("./auth");
9
+ const photos_1 = require("./photos");
10
+ const HELP = `unsplash — search and download Unsplash photos
11
+
12
+ Usage:
13
+ unsplash auth set [--stdin] [--skip-verify]
14
+ unsplash auth status [--json]
15
+ unsplash auth clear
16
+ unsplash search "<query>" [options]
17
+ unsplash download <id|url> [options]
18
+ unsplash --version
19
+
20
+ Security:
21
+ The Access Key is stored in the macOS Keychain (service "unsplash-cli").
22
+ It is never written to .env or project files. The UNSPLASH_ACCESS_KEY
23
+ environment variable overrides Keychain storage for CI.
24
+
25
+ Run \`unsplash <command> --help\` for command options.`;
26
+ const AUTH_HELP = `Usage:
27
+ unsplash auth set [--stdin] [--skip-verify] [--json]
28
+ unsplash auth status [--json]
29
+ unsplash auth clear [--json]
30
+
31
+ set Store the Access Key in macOS Keychain (prompts, input hidden).
32
+ Use --stdin to read from a pipe: pbpaste | unsplash auth set --stdin
33
+ status Show whether a key is configured; never prints the key.
34
+ clear Remove the Keychain entry.`;
35
+ const SEARCH_HELP = `Usage:
36
+ unsplash search "<query>" [options]
37
+
38
+ Options:
39
+ --per-page N Results per page (1-30, default 6)
40
+ --page N Page number (default 1)
41
+ --orientation X landscape | portrait | squarish
42
+ --color X black_and_white, black, white, yellow, orange,
43
+ red, purple, magenta, green, teal, blue
44
+ --order-by X relevant | latest (default relevant)
45
+ --download N Download the first N results (requires --out-dir)
46
+ --out-dir DIR Directory for downloaded images
47
+ --size X raw | full | regular | small | thumb (default full)
48
+ --json Machine-readable JSON
49
+ --help Show this help`;
50
+ const DOWNLOAD_HELP = `Usage:
51
+ unsplash download <id|url> [options]
52
+
53
+ Options:
54
+ --out FILE Exact output file path
55
+ --out-dir DIR Directory; file is named <photo-id>.<ext>
56
+ --size X raw | full | regular | small | thumb (default full)
57
+ --json Machine-readable JSON
58
+ --help Show this help
59
+
60
+ Each download writes a <photo-id>.json sidecar with photographer,
61
+ source page, license and download-tracking status.`;
62
+ async function main() {
63
+ const argv = process.argv.slice(2);
64
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
65
+ console.log(HELP);
66
+ return;
67
+ }
68
+ if (argv[0] === '--version' || argv[0] === '-v') {
69
+ console.log(version());
70
+ return;
71
+ }
72
+ const command = argv.shift();
73
+ switch (command) {
74
+ case 'auth':
75
+ await runAuth(argv);
76
+ return;
77
+ case 'search':
78
+ await runSearch(argv);
79
+ return;
80
+ case 'download':
81
+ await runDownload(argv);
82
+ return;
83
+ default:
84
+ throw new Error(`Unknown command: ${command}. Run \`unsplash --help\`.`);
85
+ }
86
+ }
87
+ async function runAuth(argv) {
88
+ const sub = argv[0];
89
+ if (!sub || sub === '--help' || sub === '-h' || argv.includes('--help') || argv.includes('-h')) {
90
+ console.log(AUTH_HELP);
91
+ return;
92
+ }
93
+ if (sub === 'set') {
94
+ const { values } = (0, node_util_1.parseArgs)({
95
+ args: argv.slice(1),
96
+ options: {
97
+ stdin: { type: 'boolean' },
98
+ 'skip-verify': { type: 'boolean' },
99
+ json: { type: 'boolean' },
100
+ },
101
+ allowPositionals: false,
102
+ strict: true,
103
+ });
104
+ const key = await (0, auth_1.readSecret)('Unsplash Access Key: ', Boolean(values.stdin));
105
+ if (!key)
106
+ throw new Error('No key provided.');
107
+ if (!values['skip-verify']) {
108
+ const api = new api_1.UnsplashApi(key);
109
+ try {
110
+ await api.random();
111
+ }
112
+ catch (error) {
113
+ if (error instanceof api_1.ApiError && error.status === 403) {
114
+ console.error('warning: key could not be verified because the rate limit is exhausted; saving anyway.');
115
+ }
116
+ else {
117
+ throw error;
118
+ }
119
+ }
120
+ }
121
+ (0, auth_1.writeKeychainKey)(key);
122
+ if (values.json) {
123
+ console.log(JSON.stringify({ configured: true, source: 'keychain' }, null, 2));
124
+ }
125
+ else {
126
+ console.log('Saved Unsplash Access Key to macOS Keychain (service: unsplash-cli).');
127
+ }
128
+ return;
129
+ }
130
+ if (sub === 'status') {
131
+ const { values } = (0, node_util_1.parseArgs)({
132
+ args: argv.slice(1),
133
+ options: { json: { type: 'boolean' } },
134
+ allowPositionals: false,
135
+ strict: true,
136
+ });
137
+ const env = Boolean((0, auth_1.envKey)());
138
+ const keychain = (0, auth_1.keychainStatus)();
139
+ const source = env ? 'env' : keychain === 'configured' ? 'keychain' : null;
140
+ const configured = source !== null;
141
+ if (values.json) {
142
+ console.log(JSON.stringify({ configured, source, env, keychain }, null, 2));
143
+ return;
144
+ }
145
+ console.log(`Configured: ${configured ? 'yes' : 'no'}`);
146
+ if (configured) {
147
+ console.log(`Source: ${source === 'env' ? 'UNSPLASH_ACCESS_KEY environment variable' : 'macOS Keychain (service: unsplash-cli)'}`);
148
+ }
149
+ console.log(`Keychain: ${keychain}`);
150
+ console.log(`Environment: ${env ? 'set' : 'not set'}`);
151
+ if (!configured)
152
+ console.log('\nRun: unsplash auth set');
153
+ return;
154
+ }
155
+ if (sub === 'clear') {
156
+ const { values } = (0, node_util_1.parseArgs)({
157
+ args: argv.slice(1),
158
+ options: { json: { type: 'boolean' } },
159
+ allowPositionals: false,
160
+ strict: true,
161
+ });
162
+ const removed = (0, auth_1.deleteKeychainKey)();
163
+ if (values.json) {
164
+ console.log(JSON.stringify({ cleared: removed }, null, 2));
165
+ return;
166
+ }
167
+ console.log(removed
168
+ ? 'Removed Unsplash Access Key from macOS Keychain.'
169
+ : 'No Keychain entry found.');
170
+ if ((0, auth_1.envKey)()) {
171
+ console.error('warning: UNSPLASH_ACCESS_KEY is still set in this environment and will take precedence.');
172
+ }
173
+ return;
174
+ }
175
+ throw new Error(`Unknown auth command: ${sub}. Run \`unsplash auth --help\`.`);
176
+ }
177
+ async function runSearch(argv) {
178
+ const { values, positionals } = (0, node_util_1.parseArgs)({
179
+ args: argv,
180
+ options: {
181
+ 'per-page': { type: 'string' },
182
+ page: { type: 'string' },
183
+ orientation: { type: 'string' },
184
+ color: { type: 'string' },
185
+ 'order-by': { type: 'string' },
186
+ download: { type: 'string' },
187
+ 'out-dir': { type: 'string' },
188
+ size: { type: 'string' },
189
+ json: { type: 'boolean' },
190
+ help: { type: 'boolean', short: 'h' },
191
+ },
192
+ allowPositionals: true,
193
+ strict: true,
194
+ });
195
+ if (values.help) {
196
+ console.log(SEARCH_HELP);
197
+ return;
198
+ }
199
+ const query = positionals.join(' ').trim();
200
+ if (!query)
201
+ throw new Error('search requires a query.');
202
+ const perPage = intOption(values['per-page'], '--per-page', 6, 1, 30);
203
+ const page = intOption(values.page, '--page', 1, 1, Number.MAX_SAFE_INTEGER);
204
+ const downloadCount = intOption(values.download, '--download', 0, 0, 30);
205
+ const size = sizeOption(values.size);
206
+ const outDir = values['out-dir'];
207
+ if (downloadCount > 0 && !outDir)
208
+ throw new Error('--out-dir is required with --download.');
209
+ const { key } = (0, auth_1.resolveKey)();
210
+ const api = new api_1.UnsplashApi(key);
211
+ const data = await api.search(query, {
212
+ perPage,
213
+ page,
214
+ orientation: values.orientation,
215
+ color: values.color,
216
+ orderBy: values['order-by'],
217
+ });
218
+ const results = data.results.map((photo) => (0, photos_1.compactPhoto)(photo, query));
219
+ const downloads = [];
220
+ if (downloadCount > 0 && outDir) {
221
+ for (const result of results.slice(0, downloadCount)) {
222
+ downloads.push(await (0, photos_1.downloadPhoto)(api, result.id, { outDir, size }));
223
+ }
224
+ }
225
+ if (values.json) {
226
+ console.log(JSON.stringify({
227
+ query,
228
+ page,
229
+ total: data.total,
230
+ totalPages: data.total_pages,
231
+ rate: api.lastRate,
232
+ results,
233
+ ...(downloads.length ? { downloads } : {}),
234
+ }, null, 2));
235
+ return;
236
+ }
237
+ printSearch({
238
+ query,
239
+ page,
240
+ total: data.total,
241
+ totalPages: data.total_pages,
242
+ rate: api.lastRate,
243
+ results,
244
+ downloads,
245
+ });
246
+ }
247
+ async function runDownload(argv) {
248
+ const { values, positionals } = (0, node_util_1.parseArgs)({
249
+ args: argv,
250
+ options: {
251
+ out: { type: 'string' },
252
+ 'out-dir': { type: 'string' },
253
+ size: { type: 'string' },
254
+ json: { type: 'boolean' },
255
+ help: { type: 'boolean', short: 'h' },
256
+ },
257
+ allowPositionals: true,
258
+ strict: true,
259
+ });
260
+ if (values.help) {
261
+ console.log(DOWNLOAD_HELP);
262
+ return;
263
+ }
264
+ const ref = positionals[0];
265
+ if (!ref)
266
+ throw new Error('download requires a photo id or URL.');
267
+ if (!values.out && !values['out-dir']) {
268
+ throw new Error('download requires --out or --out-dir.');
269
+ }
270
+ const size = sizeOption(values.size);
271
+ const { key } = (0, auth_1.resolveKey)();
272
+ const api = new api_1.UnsplashApi(key);
273
+ const record = await (0, photos_1.downloadPhoto)(api, ref, {
274
+ out: values.out,
275
+ outDir: values['out-dir'],
276
+ size,
277
+ });
278
+ if (values.json) {
279
+ console.log(JSON.stringify(record, null, 2));
280
+ return;
281
+ }
282
+ console.log(`File: ${record.file}`);
283
+ if (record.sidecar)
284
+ console.log(`Sidecar: ${record.sidecar}`);
285
+ console.log(`Size: ${record.width}x${record.height} (${record.size})`);
286
+ console.log(`Credit: ${record.attribution}`);
287
+ console.log(`Page: ${record.photo_page}`);
288
+ }
289
+ function printSearch(result) {
290
+ console.log(`${result.total} results for "${result.query}" (page ${result.page}/${result.totalPages})`);
291
+ result.results.forEach((photo, index) => {
292
+ console.log(`\n${index + 1}. ${photo.id} ${photo.width}x${photo.height}`);
293
+ console.log(` ${photo.description || '(no description)'}`);
294
+ console.log(` ${photo.attribution}`);
295
+ console.log(` ${photo.photo_page}`);
296
+ });
297
+ if (result.downloads.length) {
298
+ console.log('\nDownloaded:');
299
+ result.downloads.forEach((item) => {
300
+ console.log(`- ${item.file} (${item.attribution})`);
301
+ });
302
+ }
303
+ console.log(`\nRate: ${result.rate.remaining ?? '?'}/${result.rate.limit ?? '?'} remaining`);
304
+ }
305
+ function intOption(value, flag, fallback, min, max) {
306
+ if (value === undefined)
307
+ return fallback;
308
+ const parsed = Number(value);
309
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
310
+ throw new Error(`${flag} must be an integer between ${min} and ${max}.`);
311
+ }
312
+ return parsed;
313
+ }
314
+ function sizeOption(value) {
315
+ const size = (value ?? 'full');
316
+ if (!photos_1.SIZES.includes(size)) {
317
+ throw new Error(`--size must be one of: ${photos_1.SIZES.join(', ')}.`);
318
+ }
319
+ return size;
320
+ }
321
+ function version() {
322
+ try {
323
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, '..', 'package.json'), 'utf8'));
324
+ return pkg.version ?? '0.0.0';
325
+ }
326
+ catch {
327
+ return '0.0.0';
328
+ }
329
+ }
330
+ if (require.main === module) {
331
+ main().catch((error) => {
332
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
333
+ process.exit(1);
334
+ });
335
+ }
package/dist/photos.js ADDED
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SIZES = void 0;
4
+ exports.withUtm = withUtm;
5
+ exports.compactPhoto = compactPhoto;
6
+ exports.parsePhotoId = parsePhotoId;
7
+ exports.downloadPhoto = downloadPhoto;
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = require("node:path");
10
+ exports.SIZES = ['raw', 'full', 'regular', 'small', 'thumb'];
11
+ function withUtm(url) {
12
+ if (!url)
13
+ return '';
14
+ const separator = url.includes('?') ? '&' : '?';
15
+ return `${url}${separator}utm_source=unsplash-cli&utm_medium=referral`;
16
+ }
17
+ function compactPhoto(photo, query = '') {
18
+ const user = photo.user ?? {};
19
+ const profile = user.links?.html || (user.username ? `https://unsplash.com/@${user.username}` : '');
20
+ const name = user.name || user.username || 'Unknown';
21
+ return {
22
+ id: photo.id,
23
+ description: photo.description || photo.alt_description || '',
24
+ width: photo.width,
25
+ height: photo.height,
26
+ color: photo.color || '',
27
+ urls: photo.urls,
28
+ photographer: {
29
+ name,
30
+ username: user.username || '',
31
+ profile_url: withUtm(profile),
32
+ },
33
+ photo_page: withUtm(photo.links?.html || ''),
34
+ download_location: photo.links?.download_location || '',
35
+ attribution: `Photo by ${name} on Unsplash (${withUtm(profile)})`,
36
+ ...(query ? { query } : {}),
37
+ };
38
+ }
39
+ function parsePhotoId(ref) {
40
+ if (/^[A-Za-z0-9_-]{6,}$/.test(ref))
41
+ return ref;
42
+ let parsed;
43
+ try {
44
+ parsed = new URL(ref);
45
+ }
46
+ catch {
47
+ throw new Error(`Invalid photo id or URL: ${ref}`);
48
+ }
49
+ const parts = parsed.pathname.split('/').filter(Boolean);
50
+ let last = parts.pop() ?? '';
51
+ if (last === 'download')
52
+ last = parts.pop() ?? '';
53
+ const id = last.includes('-') ? last.slice(last.lastIndexOf('-') + 1) : last;
54
+ if (!id)
55
+ throw new Error(`Could not parse photo id from URL: ${ref}`);
56
+ return id;
57
+ }
58
+ async function downloadPhoto(api, ref, options) {
59
+ const id = parsePhotoId(ref);
60
+ const photo = await api.photo(id);
61
+ const meta = compactPhoto(photo);
62
+ const source = photo.urls[options.size] ?? photo.urls.full ?? photo.urls.regular;
63
+ if (!source)
64
+ throw new Error(`Photo ${id} has no image URL for size ${options.size}.`);
65
+ const image = await api.fetchImage(source);
66
+ const extension = extensionFor(image.contentType);
67
+ const file = options.out ?? (0, node_path_1.join)(options.outDir ?? '.', `${id}${extension}`);
68
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(file), { recursive: true });
69
+ (0, node_fs_1.writeFileSync)(file, image.buffer);
70
+ const shouldTrack = options.track !== false && Boolean(meta.download_location);
71
+ const tracked = shouldTrack ? await api.track(meta.download_location) : false;
72
+ if (shouldTrack && !tracked) {
73
+ console.error('warning: Unsplash download tracking failed; the image was saved.');
74
+ }
75
+ const record = {
76
+ provider: 'unsplash',
77
+ ...meta,
78
+ size: options.size,
79
+ file,
80
+ bytes: image.buffer.length,
81
+ download_tracked: tracked,
82
+ downloaded_at: new Date().toISOString(),
83
+ license: 'https://unsplash.com/license',
84
+ sidecar: null,
85
+ };
86
+ if (options.sidecar !== false) {
87
+ const sidecar = (0, node_path_1.join)((0, node_path_1.dirname)(file), `${(0, node_path_1.basename)(file, (0, node_path_1.extname)(file))}.json`);
88
+ (0, node_fs_1.writeFileSync)(sidecar, `${JSON.stringify(record, null, 2)}\n`);
89
+ record.sidecar = sidecar;
90
+ }
91
+ return record;
92
+ }
93
+ function extensionFor(contentType) {
94
+ if (contentType.includes('png'))
95
+ return '.png';
96
+ if (contentType.includes('webp'))
97
+ return '.webp';
98
+ if (contentType.includes('gif'))
99
+ return '.gif';
100
+ return '.jpg';
101
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@orangemust/unsplash-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Search and download Unsplash photos from the command line. API key stored in macOS Keychain, never in .env.",
5
+ "keywords": [
6
+ "unsplash",
7
+ "photos",
8
+ "images",
9
+ "stock-photos",
10
+ "cli",
11
+ "agent",
12
+ "automation"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "mack-peng",
16
+ "main": "dist/index.js",
17
+ "bin": {
18
+ "unsplash": "bin/unsplash.js",
19
+ "unsplash-cli": "bin/unsplash.js"
20
+ },
21
+ "files": [
22
+ "bin",
23
+ "dist",
24
+ "README.md",
25
+ "LICENSE",
26
+ "AGENTS.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc",
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "tsx --test tests/*.test.ts",
35
+ "dev": "tsx src/index.ts",
36
+ "prepublishOnly": "npm run build"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.0.0",
40
+ "tsx": "^4.19.0",
41
+ "typescript": "^5.6.0"
42
+ }
43
+ }