@yuiseki/gyazocli 0.9.0 → 0.10.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/README.md CHANGED
@@ -196,6 +196,27 @@ already configured against that server can point at this one instead. Its
196
196
  `gyazo_upload` is deliberately absent: nothing here can write to your Gyazo
197
197
  account until there is a reason for it to.
198
198
 
199
+ ## Triage
200
+
201
+ Going through captures one at a time, deciding something about each:
202
+
203
+ ```bash
204
+ gyazo triage -q "password"
205
+ gyazo triage --id <image_id> # revisit one
206
+ ```
207
+
208
+ It prints everything a capture carries as markdown, one heading per field, and
209
+ at a terminal asks `Is it safe? [Y/n]` after each. Answers are appended to
210
+ `${XDG_STATE_HOME:-~/.local/state}/gyazocli/triage.jsonl`, outside the cache,
211
+ and a capture already answered is skipped next time.
212
+
213
+ Answering `n` sets that capture to `only_me`. The public API takes
214
+ `access_policy` at upload and has no endpoint that changes it afterwards, so
215
+ this needs gyazo.com cookies, from `GYAZO_COOKIE_FILE` or
216
+ `~/.config/gyazo/cookie.json` (a browser export works as-is). Without them it
217
+ prints the capture's page URL instead and says nothing was changed, and
218
+ `--no-apply` turns the change off entirely.
219
+
199
220
  ## Agent skill
200
221
 
201
222
  `skills/gyazo/` is a skill for coding agents that drive the CLI: what the
package/dist/api.js CHANGED
@@ -12,6 +12,7 @@ exports.listCollections = listCollections;
12
12
  exports.getCollectionDetail = getCollectionDetail;
13
13
  exports.listCollectionImages = listCollectionImages;
14
14
  exports.fetchImageRendition = fetchImageRendition;
15
+ exports.setAccessPolicy = setAccessPolicy;
15
16
  exports.uploadImage = uploadImage;
16
17
  const axios_1 = __importDefault(require("axios"));
17
18
  const form_data_1 = __importDefault(require("form-data"));
@@ -50,6 +51,15 @@ async function requestWithRetry(url, params = {}, headers) {
50
51
  return response.data;
51
52
  }
52
53
  catch (error) {
54
+ if (error.response && error.response.status === 401) {
55
+ // The status alone reads as a bug in the caller. It is not: the token
56
+ // is present and Gyazo will not take it. That happens when it is
57
+ // mistyped, when it has been revoked, and when Gyazo revokes tokens in
58
+ // bulk, as it did after the 2026-09-11 incident.
59
+ throw new Error('Gyazo rejected the access token (401). Issue a new one at ' +
60
+ 'https://gyazo.com/oauth/applications and save it with ' +
61
+ '`gyazo config set token <token>`.');
62
+ }
53
63
  if (error.response && error.response.status === 429) {
54
64
  const retryAfter = parseInt(error.response.headers['retry-after'] || '5', 10);
55
65
  console.warn(`Rate limited. Retrying after ${retryAfter} seconds...`);
@@ -136,6 +146,37 @@ async function fetchImageRendition(imageId, width, format = 'webp') {
136
146
  format,
137
147
  };
138
148
  }
149
+ /**
150
+ * The web app's own endpoint for an existing capture.
151
+ *
152
+ * The public API takes `access_policy` when uploading and offers nothing that
153
+ * changes it afterwards, so this speaks to the same route the site does. It
154
+ * needs the session cookie and the CSRF token that goes with it: without the
155
+ * token the request comes back 422 with an empty body.
156
+ */
157
+ async function setAccessPolicy(imageId, accessPolicy, cookieHeader) {
158
+ const page = await axios_1.default.get(`${webOrigin()}/${imageId}`, {
159
+ headers: { Cookie: cookieHeader },
160
+ responseType: 'text',
161
+ // A redirect to the login page means the cookies are stale; let it be
162
+ // seen rather than followed into an HTML page with no token.
163
+ maxRedirects: 0,
164
+ validateStatus: (status) => status >= 200 && status < 400,
165
+ });
166
+ const token = String(page.data).match(/<meta name="csrf-token" content="([^"]+)"/)?.[1];
167
+ if (!token) {
168
+ throw new Error('could not read a CSRF token from gyazo.com; the cookies are probably expired');
169
+ }
170
+ const response = await axios_1.default.patch(`${webOrigin()}/api/internal/images/${imageId}`, { access_policy: accessPolicy }, {
171
+ headers: {
172
+ Cookie: cookieHeader,
173
+ 'Content-Type': 'application/json',
174
+ 'X-CSRF-Token': token,
175
+ 'X-Requested-With': 'XMLHttpRequest',
176
+ },
177
+ });
178
+ return response.data;
179
+ }
139
180
  async function uploadImage(options) {
140
181
  const form = new form_data_1.default();
141
182
  form.append('access_token', config_1.config.GYAZO_ACCESS_TOKEN || '');
@@ -2,13 +2,31 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerConfigCommand = registerConfigCommand;
4
4
  const api_1 = require("../api");
5
+ const config_1 = require("../config");
5
6
  const credentials_1 = require("../credentials");
6
7
  function registerConfigCommand(program) {
7
8
  const configCmd = program.command('config').description('Manage configuration');
8
9
  configCmd
9
10
  .command('set <key> <value>')
10
11
  .description('Set a configuration value')
11
- .action((key, value) => {
12
+ .option('--no-verify', 'save a token without checking it against the API first')
13
+ .action(async (key, value, options) => {
14
+ // A token that Gyazo will not accept is worth catching here rather than
15
+ // at the next command: the page it comes from shows several strings of
16
+ // the same shape, and a saved bad token replaces a good one.
17
+ if (key === 'token' && options.verify !== false) {
18
+ (0, config_1.setAccessToken)(value);
19
+ try {
20
+ await (0, api_1.getCurrentUser)();
21
+ }
22
+ catch (error) {
23
+ console.error(`Error: this token was not accepted. ${error.message}`);
24
+ console.error('Nothing was saved. Check that the value is the access token');
25
+ console.error('from https://gyazo.com/oauth/applications, not the client ID or secret.');
26
+ console.error('Save it anyway with --no-verify.');
27
+ process.exit(1);
28
+ }
29
+ }
12
30
  (0, credentials_1.setStoredConfig)(key, value);
13
31
  });
14
32
  configCmd
@@ -0,0 +1,358 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.highlightTermsOf = highlightTermsOf;
7
+ exports.registerTriageCommand = registerTriageCommand;
8
+ /**
9
+ * The `triage` command: a search, rendered as markdown for reading rather than
10
+ * for parsing.
11
+ *
12
+ * `ls` and `search` print one line per capture, which is the right shape for
13
+ * scanning a day. Going through captures one at a time, deciding something
14
+ * about each, wants the opposite: everything a capture carries, laid out, with
15
+ * nothing invented and nothing hidden.
16
+ */
17
+ const node_readline_1 = __importDefault(require("node:readline"));
18
+ const api_1 = require("../api");
19
+ const credentials_1 = require("../credentials");
20
+ const format_1 = require("../format");
21
+ const cookies_1 = require("../cookies");
22
+ const ids_1 = require("../ids");
23
+ const options_1 = require("../options");
24
+ const memory_1 = require("../services/memory");
25
+ const triage_ledger_1 = require("../services/triage-ledger");
26
+ /**
27
+ * Fields worth a heading, in the order a person reads them. The id, the URLs
28
+ * and the coordinates are deliberately absent: the id is the heading, and the
29
+ * rest is noise when the job is deciding something about a capture.
30
+ */
31
+ const FIELD_ORDER = [
32
+ // First, because it is what a triage decision often turns on.
33
+ 'access_policy',
34
+ 'created_at',
35
+ 'app',
36
+ 'title',
37
+ 'desc',
38
+ 'page_url',
39
+ 'alt_text',
40
+ 'ocr',
41
+ 'address',
42
+ 'objects',
43
+ ];
44
+ const CYAN = '\u001b[36m';
45
+ const RESET = '\u001b[0m';
46
+ const ORANGE = '\u001b[38;5;208m';
47
+ /** Back to the default foreground, rather than resetting every attribute. */
48
+ const PLAIN = '\u001b[39m';
49
+ /** Operators whose value never appears in the text, so painting it is noise. */
50
+ const STRUCTURAL_KEYS = new Set(['has', 'type', 'date', 'since', 'until']);
51
+ /**
52
+ * The parts of a query that can show up in what a capture says: bare words,
53
+ * and the values of the operators that match text. `has:exif` contributes
54
+ * nothing, and a negated term should not be there to find.
55
+ */
56
+ function highlightTermsOf(query) {
57
+ const terms = [];
58
+ // Quoted values hold spaces: app:"Gyazo Android".
59
+ for (const token of query.match(/(?:[^\s"]|"[^"]*")+/g) || []) {
60
+ if (token.startsWith('-') || token === 'OR' || token === 'or')
61
+ continue;
62
+ const separator = token.indexOf(':');
63
+ const raw = separator === -1 ? token : token.slice(separator + 1);
64
+ if (separator !== -1 && STRUCTURAL_KEYS.has(token.slice(0, separator).toLowerCase()))
65
+ continue;
66
+ const value = raw.replace(/^"|"$/g, '').trim();
67
+ if (value)
68
+ terms.push(value);
69
+ }
70
+ return terms;
71
+ }
72
+ function escapeForRegExp(value) {
73
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74
+ }
75
+ /** Paint every occurrence of a term, keeping the text's own case. */
76
+ function highlight(value, terms) {
77
+ if (terms.length === 0)
78
+ return value;
79
+ const pattern = new RegExp(terms.map(escapeForRegExp).join('|'), 'gi');
80
+ return value.replace(pattern, (match) => `${ORANGE}${match}${PLAIN}`);
81
+ }
82
+ function text(value) {
83
+ if (value === null || value === undefined)
84
+ return undefined;
85
+ if (typeof value === 'string')
86
+ return (0, format_1.normalizeText)(value);
87
+ if (typeof value === 'number' || typeof value === 'boolean')
88
+ return String(value);
89
+ return undefined;
90
+ }
91
+ /**
92
+ * What a capture actually carries, flattened to a name and a block of text.
93
+ * A field that is absent, null or empty is left out rather than printed as a
94
+ * heading with nothing under it.
95
+ */
96
+ function fieldsOf(image) {
97
+ const metadata = image?.metadata || {};
98
+ const ocr = metadata.ocr ?? image?.ocr;
99
+ const exif = metadata.exif_normalized ?? image?.exif_normalized;
100
+ const address = metadata.exif_address?.ja?.address ?? metadata.exif_address?.en?.address ?? undefined;
101
+ const objects = Array.isArray(metadata.localized_object_annotations)
102
+ ? metadata.localized_object_annotations
103
+ : metadata.localizedObjectAnnotations;
104
+ const candidates = {
105
+ // The API leaves this unset on most captures, where it means the default.
106
+ // Reading a hundred sections looking for the ones that are not `anyone`
107
+ // is easier when every section says which it is.
108
+ access_policy: text(image?.access_policy) ?? 'anyone',
109
+ created_at: image?.created_at ? (0, format_1.formatCreatedAtJa)(image.created_at) : undefined,
110
+ type: text(image?.type),
111
+ permalink_url: text(image?.permalink_url),
112
+ url: text(image?.url),
113
+ app: text(metadata.app),
114
+ title: text(metadata.title),
115
+ desc: text(metadata.desc),
116
+ page_url: text(metadata.url),
117
+ alt_text: text(image?.alt_text),
118
+ ocr: text(ocr?.description),
119
+ ocr_locale: text(ocr?.locale),
120
+ latitude: text(exif?.latitude),
121
+ longitude: text(exif?.longitude),
122
+ address: text(address),
123
+ objects: Array.isArray(objects)
124
+ ? objects
125
+ .map((o) => {
126
+ const name = text(o?.name_ja || o?.nameJa || o?.name);
127
+ const score = typeof o?.score === 'number' ? ` (${(o.score * 100).toFixed(1)}%)` : '';
128
+ return name ? `${name}${score}` : undefined;
129
+ })
130
+ .filter(Boolean)
131
+ .join('\n') || undefined
132
+ : undefined,
133
+ };
134
+ return FIELD_ORDER.map((key) => [key, candidates[key]]).filter((entry) => Boolean(entry[1]));
135
+ }
136
+ function registerTriageCommand(program) {
137
+ program
138
+ .command('triage [query]')
139
+ .description('Search, and go through the captures one at a time')
140
+ .option('-q, --query <query>', 'the search query')
141
+ .option('-l, --limit <number>', 'how many captures to go through in this run', '20')
142
+ .option('--max-pages <number>', 'how many search pages to walk looking for them', '20')
143
+ .option('--id <image_id...>', 'go through exactly these captures, answered or not')
144
+ .option('--color <when>', 'colour the headings: auto, always or never', 'auto')
145
+ .option('-i, --interactive', 'ask about each capture and record the answer')
146
+ .option('--no-interactive', 'print without asking, even at a terminal')
147
+ .option('--again', 'ask again about captures already answered')
148
+ .option('--cookies <path>', 'gyazo.com cookies, for making a capture private')
149
+ .option('--no-apply', 'answer without changing anything on Gyazo')
150
+ .option('--no-cache', 'force fetch from API')
151
+ .action(async (positional, options) => {
152
+ await (0, credentials_1.ensureAccessToken)();
153
+ const ids = options.id || [];
154
+ const query = (0, format_1.normalizeText)(options.query || positional) || '';
155
+ if (!query && ids.length === 0) {
156
+ console.error('Error: Query is required.');
157
+ console.error('Hint: gyazo triage -q "password"');
158
+ console.error(' gyazo triage --id <image_id> to revisit one');
159
+ process.exit(1);
160
+ }
161
+ const limit = (0, options_1.parsePositiveIntegerOption)(options.limit, '--limit');
162
+ const maxPages = (0, options_1.parsePositiveIntegerOption)(options.maxPages, '--max-pages');
163
+ // Colour when a person is reading, not when the output is being piped
164
+ // into a file. `--color always` is for a pager that understands it.
165
+ const colour = options.color === 'always' ||
166
+ (options.color !== 'never' && Boolean(process.stdout.isTTY) && !process.env.NO_COLOR);
167
+ // Asking only makes sense with someone there to answer. A pipe on either
168
+ // side means this is feeding a file, not a person.
169
+ const interactive = options.interactive === true ||
170
+ (options.interactive !== false &&
171
+ Boolean(process.stdin.isTTY) &&
172
+ Boolean(process.stdout.isTTY));
173
+ const terms = colour ? highlightTermsOf(query) : [];
174
+ // Read once, so a long triage does not touch the file per answer.
175
+ const cookieHeader = options.apply === false ? undefined : (0, cookies_1.loadCookieHeader)(options.cookies);
176
+ const print = (image, index) => {
177
+ console.log('');
178
+ if (index > 0) {
179
+ console.log('---');
180
+ console.log('');
181
+ }
182
+ const heading = `# ${image.image_id}`;
183
+ console.log(colour ? `${CYAN}${heading}${RESET}` : heading);
184
+ for (const [key, value] of fieldsOf(image)) {
185
+ console.log('');
186
+ console.log(`## ${key}`);
187
+ console.log('');
188
+ console.log(highlight(value, terms));
189
+ }
190
+ };
191
+ const goThrough = async (captures, skipped, pagesWalked) => {
192
+ if (!interactive) {
193
+ captures.forEach(print);
194
+ return;
195
+ }
196
+ // Lines are pulled from an iterator rather than asked for with
197
+ // rl.question: with piped input, question() resolves once and then
198
+ // never again, so everything after the first answer is lost.
199
+ const lines = node_readline_1.default.createInterface({ input: process.stdin });
200
+ const answers = lines[Symbol.asyncIterator]();
201
+ const ask = async (prompt) => {
202
+ process.stdout.write(prompt);
203
+ const { value, done } = await answers.next();
204
+ return done ? null : String(value);
205
+ };
206
+ const counts = { safe: 0, unsafe: 0 };
207
+ const toMakePrivate = [];
208
+ let madePrivate = 0;
209
+ let warnedAboutCookies = false;
210
+ let asked = 0;
211
+ try {
212
+ for (const [index, image] of captures.entries()) {
213
+ print(image, index);
214
+ console.log('');
215
+ const raw = await ask('Is it safe? [Y/n] ');
216
+ // Enter takes the default. q stops, and so does end of input: no
217
+ // answer is not the same as "safe".
218
+ if (raw === null) {
219
+ console.log('');
220
+ break;
221
+ }
222
+ const answer = raw.trim().toLowerCase();
223
+ if (answer === 'q' || answer === 'quit')
224
+ break;
225
+ const verdict = answer === 'n' || answer === 'no' ? 'unsafe' : 'safe';
226
+ (0, triage_ledger_1.appendTriageEntry)({
227
+ image_id: image.image_id,
228
+ verdict,
229
+ at: new Date().toISOString(),
230
+ query: query || undefined,
231
+ });
232
+ counts[verdict]++;
233
+ asked++;
234
+ if (verdict === 'unsafe') {
235
+ const link = image.permalink_url || `https://gyazo.com/${image.image_id}`;
236
+ const paint = (line) => (colour ? `${ORANGE}${line}${PLAIN}` : line);
237
+ if (options.apply === false) {
238
+ toMakePrivate.push(link);
239
+ console.log(paint(`→ ${link}`));
240
+ }
241
+ else if (!cookieHeader) {
242
+ // The public API has no way to change this, so without
243
+ // cookies the honest move is to hand over the link and say
244
+ // why, once.
245
+ toMakePrivate.push(link);
246
+ console.log(paint(`→ ${link}`));
247
+ if (!warnedAboutCookies) {
248
+ console.log(' (no gyazo.com cookies found, so nothing was changed. Put them in ' +
249
+ '~/.config/gyazo/cookie.json to have this set only_me for you.)');
250
+ warnedAboutCookies = true;
251
+ }
252
+ }
253
+ else {
254
+ try {
255
+ await (0, api_1.setAccessPolicy)(image.image_id, 'only_me', cookieHeader);
256
+ console.log(paint(`→ only_me: ${link}`));
257
+ madePrivate++;
258
+ }
259
+ catch (error) {
260
+ // A failure here is the interesting kind: the answer is
261
+ // recorded, the capture is still public, and saying so is
262
+ // the only way the difference is visible.
263
+ toMakePrivate.push(link);
264
+ console.log(paint(`→ ${link}`));
265
+ console.log(` could not set only_me: ${error.message}`);
266
+ }
267
+ }
268
+ }
269
+ }
270
+ }
271
+ finally {
272
+ lines.close();
273
+ }
274
+ console.log('');
275
+ console.log(`${asked} answered: ${counts.safe} safe, ${counts.unsafe} unsafe` +
276
+ (madePrivate > 0 ? `, ${madePrivate} set only_me` : '') +
277
+ '.');
278
+ if (skipped > 0) {
279
+ console.log(`${skipped} skipped as already answered, ${pagesWalked} pages walked.`);
280
+ }
281
+ if (toMakePrivate.length > 0) {
282
+ console.log('');
283
+ console.log(`${toMakePrivate.length} to make private (only_me), on their own pages:`);
284
+ for (const link of toMakePrivate)
285
+ console.log(` ${link}`);
286
+ }
287
+ console.log(`Written to ${(0, triage_ledger_1.getTriageLedgerPath)()}`);
288
+ };
289
+ try {
290
+ console.log(`triage: ${query || ids.join(', ')}`);
291
+ // Named captures are fetched as named, and asked about whether or not
292
+ // they were answered before: naming one is how a mistake is corrected.
293
+ if (ids.length > 0) {
294
+ const named = [];
295
+ for (const given of ids) {
296
+ const imageId = (0, ids_1.normalizeImageId)(given);
297
+ if (!imageId) {
298
+ console.error(`Error: '${given}' is not a Gyazo image ID or URL.`);
299
+ process.exit(1);
300
+ }
301
+ named.push(await (0, api_1.getImageDetail)(imageId));
302
+ }
303
+ await goThrough(named, 0, 0);
304
+ return;
305
+ }
306
+ // Walk the search until `limit` captures that have not been answered
307
+ // are in hand. A page where everything is already answered is not the
308
+ // end of the road, which is what stopping at one page made it.
309
+ const answered = options.again ? new Map() : (0, triage_ledger_1.loadTriageVerdicts)();
310
+ const selected = [];
311
+ let pagesWalked = 0;
312
+ let skipped = 0;
313
+ for (let page = 1; page <= maxPages && selected.length < limit; page++) {
314
+ const found = await (0, api_1.searchImages)(query, page, 100);
315
+ pagesWalked = page;
316
+ if (!found || found.length === 0)
317
+ break;
318
+ for (const image of found) {
319
+ if (answered.has(image.image_id)) {
320
+ skipped++;
321
+ continue;
322
+ }
323
+ selected.push(image);
324
+ if (selected.length >= limit)
325
+ break;
326
+ }
327
+ if (found.length < 100)
328
+ break;
329
+ }
330
+ if (selected.length === 0) {
331
+ console.log('');
332
+ if (skipped > 0) {
333
+ console.log(`Nothing left to ask about: ${skipped} already answered, ` +
334
+ `${pagesWalked} pages walked.`);
335
+ console.log(`Answers so far: ${(0, triage_ledger_1.getTriageLedgerPath)()}`);
336
+ }
337
+ else {
338
+ console.log('No captures matched.');
339
+ }
340
+ return;
341
+ }
342
+ // The search endpoint returns a lean image: no OCR, no coordinates.
343
+ // Triage is about what a capture says, so fetch the rest.
344
+ const enriched = await (0, memory_1.enrichImageLocations)(selected, {
345
+ useCache: options.cache !== false,
346
+ limit: selected.length,
347
+ });
348
+ console.log(`${enriched.images.length} captures` +
349
+ (skipped > 0 ? `, ${skipped} already answered and skipped` : '') +
350
+ `, ${pagesWalked} pages walked.`);
351
+ await goThrough(enriched.images, skipped, pagesWalked);
352
+ }
353
+ catch (error) {
354
+ console.error('Error triaging images:', error.message);
355
+ process.exit(1);
356
+ }
357
+ });
358
+ }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.findCookieFile = findCookieFile;
7
+ exports.buildCookieHeader = buildCookieHeader;
8
+ exports.loadCookieHeader = loadCookieHeader;
9
+ /**
10
+ * Browser cookies for gyazo.com, for the one thing the API cannot do.
11
+ *
12
+ * `access_policy` is an upload parameter and there is no endpoint that updates
13
+ * it afterwards, so making an existing capture private means asking the web
14
+ * app the way the web app asks itself: a session cookie and a CSRF token.
15
+ *
16
+ * The file holds a live session. It is read when needed, never logged, and
17
+ * never written to.
18
+ */
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const os_1 = __importDefault(require("os"));
21
+ const path_1 = __importDefault(require("path"));
22
+ const GYAZO_DOMAIN = /(^|\.)gyazo\.com$/i;
23
+ /**
24
+ * Where to look, nearest intention first. A file named outright is the only
25
+ * candidate: falling back from a path someone gave would use credentials they
26
+ * did not point at, which is the last thing this should do.
27
+ */
28
+ function candidatePaths() {
29
+ if (process.env.GYAZO_COOKIE_FILE)
30
+ return [process.env.GYAZO_COOKIE_FILE];
31
+ const paths = [];
32
+ const home = os_1.default.homedir();
33
+ paths.push(path_1.default.join(home, '.config', 'gyazo', 'cookie.json'));
34
+ paths.push(path_1.default.join(home, '.config', 'gyazo', 'cookies.json'));
35
+ // Handy while working in a checkout, though it only applies from there.
36
+ paths.push(path_1.default.join(process.cwd(), '.cookies', 'gyazo.com.json'));
37
+ return paths;
38
+ }
39
+ function findCookieFile(explicit) {
40
+ const paths = explicit ? [explicit] : candidatePaths();
41
+ return paths.find((candidate) => fs_1.default.existsSync(candidate));
42
+ }
43
+ /**
44
+ * A `Cookie` header from whatever shape the file is in: a browser export
45
+ * (an array of `{name, value, domain}`), a plain `{name: value}` object, or a
46
+ * header string already. Entries belonging to another domain are left out.
47
+ */
48
+ function buildCookieHeader(contents) {
49
+ const trimmed = contents.trim();
50
+ if (trimmed === '')
51
+ return undefined;
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(trimmed);
55
+ }
56
+ catch {
57
+ // Already a header, or a cookie file this does not understand.
58
+ return trimmed.includes('=') ? trimmed : undefined;
59
+ }
60
+ const pairs = [];
61
+ if (Array.isArray(parsed)) {
62
+ for (const entry of parsed) {
63
+ if (!entry || typeof entry.name !== 'string')
64
+ continue;
65
+ if (typeof entry.domain === 'string' && !GYAZO_DOMAIN.test(entry.domain.replace(/^\./, ''))) {
66
+ continue;
67
+ }
68
+ pairs.push(`${entry.name}=${entry.value ?? ''}`);
69
+ }
70
+ }
71
+ else if (parsed && typeof parsed === 'object') {
72
+ for (const [name, value] of Object.entries(parsed)) {
73
+ if (typeof value === 'string')
74
+ pairs.push(`${name}=${value}`);
75
+ }
76
+ }
77
+ return pairs.length > 0 ? pairs.join('; ') : undefined;
78
+ }
79
+ function loadCookieHeader(explicit) {
80
+ const file = findCookieFile(explicit);
81
+ if (!file)
82
+ return undefined;
83
+ try {
84
+ return buildCookieHeader(fs_1.default.readFileSync(file, 'utf-8'));
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
package/dist/format.js CHANGED
@@ -20,6 +20,7 @@ exports.buildEnLocationLabel = buildEnLocationLabel;
20
20
  exports.extractImageAddressText = extractImageAddressText;
21
21
  exports.extractImageLocationLabel = extractImageLocationLabel;
22
22
  exports.truncateText = truncateText;
23
+ exports.formatCreatedAtJa = formatCreatedAtJa;
23
24
  exports.formatCreatedAt = formatCreatedAt;
24
25
  exports.shortenImageId = shortenImageId;
25
26
  exports.formatTerminalLink = formatTerminalLink;
@@ -229,12 +230,37 @@ function truncateText(value, maxLength) {
229
230
  return value.slice(0, maxLength);
230
231
  return `${value.slice(0, maxLength - 3)}...`;
231
232
  }
233
+ const JA_WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
234
+ /**
235
+ * A timestamp as the person who took the capture experienced it: their own
236
+ * clock, in Japanese. The API sends UTC, so this converts rather than reading
237
+ * the digits out of the string.
238
+ */
239
+ function formatCreatedAtJa(value) {
240
+ const at = new Date(value);
241
+ if (Number.isNaN(at.getTime()))
242
+ return value;
243
+ const pad = (n) => String(n).padStart(2, '0');
244
+ return (`${at.getFullYear()}年${at.getMonth() + 1}月${at.getDate()}日` +
245
+ `(${JA_WEEKDAYS[at.getDay()]}) ${pad(at.getHours())}:${pad(at.getMinutes())}`);
246
+ }
247
+ /**
248
+ * A timestamp on the reader's own clock.
249
+ *
250
+ * This used to lift the digits out of the string, which showed the API's UTC
251
+ * as if it were local time: a capture taken at 18:09 in Tokyo was listed as
252
+ * 09:09. The filters were always local, so only the display was wrong, and it
253
+ * was wrong by a whole timezone.
254
+ */
232
255
  function formatCreatedAt(value) {
233
- const match = value.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})/);
234
- if (match) {
235
- return `${match[1]} ${match[2]}:${match[3]}`;
256
+ const at = new Date(value);
257
+ if (Number.isNaN(at.getTime())) {
258
+ const match = value.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})/);
259
+ return match ? `${match[1]} ${match[2]}:${match[3]}` : value;
236
260
  }
237
- return value;
261
+ const pad = (n) => String(n).padStart(2, '0');
262
+ return (`${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ` +
263
+ `${pad(at.getHours())}:${pad(at.getMinutes())}`);
238
264
  }
239
265
  function shortenImageId(imageId) {
240
266
  if (!imageId)
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ const list_1 = require("./commands/list");
12
12
  const get_1 = require("./commands/get");
13
13
  const collection_1 = require("./commands/collection");
14
14
  const search_1 = require("./commands/search");
15
+ const triage_1 = require("./commands/triage");
15
16
  const apps_1 = require("./commands/apps");
16
17
  const domains_1 = require("./commands/domains");
17
18
  const tags_1 = require("./commands/tags");
@@ -26,12 +27,13 @@ program
26
27
  .name('gyazo')
27
28
  .description('Gyazo Memory CLI for AI Secretary')
28
29
  .option('--mcp-server', 'run as a Model Context Protocol server over stdio')
29
- .version('0.9.0');
30
+ .version('0.10.0');
30
31
  (0, config_1.registerConfigCommand)(program);
31
32
  (0, list_1.registerListCommand)(program);
32
33
  (0, get_1.registerGetCommand)(program);
33
34
  (0, collection_1.registerCollectionCommand)(program);
34
35
  (0, search_1.registerSearchCommand)(program);
36
+ (0, triage_1.registerTriageCommand)(program);
35
37
  (0, apps_1.registerAppsCommand)(program);
36
38
  (0, domains_1.registerDomainsCommand)(program);
37
39
  (0, tags_1.registerTagsCommand)(program);
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getTriageLedgerPath = getTriageLedgerPath;
7
+ exports.loadTriageEntries = loadTriageEntries;
8
+ exports.loadTriageVerdicts = loadTriageVerdicts;
9
+ exports.appendTriageEntry = appendTriageEntry;
10
+ /**
11
+ * What was decided about a capture, and where that is kept.
12
+ *
13
+ * Deliberately not under the cache: the cache holds copies of things Gyazo can
14
+ * send again, and can be deleted to reclaim disk. A judgement cannot be
15
+ * fetched again, so it lives in the state directory and survives `rm -rf
16
+ * ~/.cache/gyazocli`.
17
+ */
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const os_1 = __importDefault(require("os"));
20
+ const path_1 = __importDefault(require("path"));
21
+ function getTriageLedgerPath() {
22
+ const base = process.env.GYAZO_STATE_DIR ||
23
+ process.env.XDG_STATE_HOME ||
24
+ path_1.default.join(os_1.default.homedir(), '.local', 'state');
25
+ const dir = path_1.default.join(base, 'gyazocli');
26
+ if (!fs_1.default.existsSync(dir)) {
27
+ fs_1.default.mkdirSync(dir, { recursive: true });
28
+ }
29
+ return path_1.default.join(dir, 'triage.jsonl');
30
+ }
31
+ function loadTriageEntries() {
32
+ const file = getTriageLedgerPath();
33
+ if (!fs_1.default.existsSync(file))
34
+ return [];
35
+ return fs_1.default
36
+ .readFileSync(file, 'utf-8')
37
+ .split('\n')
38
+ .filter((line) => line.trim() !== '')
39
+ .map((line) => {
40
+ try {
41
+ return JSON.parse(line);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ })
47
+ .filter((entry) => entry !== null);
48
+ }
49
+ /** The latest verdict for each capture, since the file is append-only. */
50
+ function loadTriageVerdicts() {
51
+ const byImage = new Map();
52
+ for (const entry of loadTriageEntries()) {
53
+ byImage.set(entry.image_id, entry);
54
+ }
55
+ return byImage;
56
+ }
57
+ function appendTriageEntry(entry) {
58
+ fs_1.default.appendFileSync(getTriageLedgerPath(), `${JSON.stringify(entry)}\n`, 'utf-8');
59
+ }
@@ -13,12 +13,16 @@ Adopt and document the existing top-level command structure.
13
13
 
14
14
  ### 1. Program Metadata
15
15
  - Binary name: `gyazo`
16
- - Version: `0.9.0`
16
+ - Version: `0.10.0`
17
17
  - Description: `Gyazo Memory CLI for AI Secretary`
18
18
 
19
19
  ### 2. Commands
20
20
  - `gyazo config set <key> <value>`
21
21
  - Currently supported key: `token`
22
+ - Options:
23
+ - `--no-verify` (save a token without checking it against the API first)
24
+ - A token is checked against `/api/users/me` before it is saved, and nothing
25
+ is written when Gyazo rejects it
22
26
  - `gyazo config get <key>`
23
27
  - `token` is masked
24
28
  - `me` fetches `/api/users/me`
@@ -48,6 +52,50 @@ Adopt and document the existing top-level command structure.
48
52
  - `-l, --limit <number>` (default: `20`)
49
53
  - `-j, --json`
50
54
  - `--no-cache`
55
+ - `gyazo triage [query]`
56
+ - Markdown for reading a search result capture by capture: the image ID as
57
+ `#`, one `##` heading per field it carries, captures separated by `---`
58
+ - Options:
59
+ - `-q, --query <query>` (the query, also accepted as a bare argument)
60
+ - `-l, --limit <number>` (default: `20`; how many captures this run goes
61
+ through, not an API page size)
62
+ - `--max-pages <number>` (default: `20`; pages of 100 to walk looking for
63
+ captures not yet answered)
64
+ - `--color <auto|always|never>` (default: `auto`, meaning a terminal)
65
+ - `-i, --interactive` / `--no-interactive` (default: interactive when both
66
+ stdin and stdout are a terminal)
67
+ - `--again` (ask again about captures already answered)
68
+ - `--no-cache`
69
+ - What the query matched is painted orange, case kept. Bare words and the
70
+ values of text operators count; `has:`, `type:`, `date:`, `since:` and
71
+ `until:` values, negated terms and `OR` do not. Only when colour is on,
72
+ so piped output stays plain
73
+ - `access_policy` is printed first, and an unset one is printed as `anyone`:
74
+ the API leaves it unset on most captures, and a reader looking for
75
+ `only_me` is better served by every section saying which it is
76
+ - Captures already answered are skipped, and the walk continues to the next
77
+ page rather than stopping: a page where everything has been answered is not
78
+ the end of the results
79
+ - `--id <image_id...>` goes through exactly those captures, answered or not,
80
+ which is how a mistaken answer is corrected. The ledger is append-only and
81
+ the last answer for a capture wins
82
+ - Answering `n` sets the capture to `only_me` when gyazo.com cookies are
83
+ available, and otherwise prints its page URL and says why. Gyazo's public
84
+ API takes `access_policy` at upload and has no endpoint that changes it
85
+ afterwards, so this speaks to the web app's own route the way the site
86
+ does: a session cookie plus the CSRF token from the capture's page. Without
87
+ the token the request answers 422 with an empty body
88
+ - Cookies are looked for at `GYAZO_COOKIE_FILE`, then
89
+ `~/.config/gyazo/cookie.json`, then `~/.config/gyazo/cookies.json`, then
90
+ `./.cookies/gyazo.com.json`. A path given outright is the only candidate.
91
+ A browser export, a `{name: value}` object and a header string are all
92
+ accepted, and entries for other domains are dropped
93
+ - `--no-apply` answers without changing anything
94
+ - Interactive mode asks `Is it safe? [Y/n]` per capture. Enter takes the
95
+ default, `n` marks it unsafe, `q` or end of input stops and keeps what was
96
+ answered. Answers are appended to
97
+ `${XDG_STATE_HOME:-~/.local/state}/gyazocli/triage.jsonl`, outside the
98
+ cache, because a judgement cannot be fetched again
51
99
  - `gyazo apps`
52
100
  - Default range: from 8 days ago to yesterday
53
101
  - Options:
@@ -126,6 +174,10 @@ Adopt and document the existing top-level command structure.
126
174
  - Supported types: `json`, `hourly`
127
175
 
128
176
  ### 3. Output and Behavior Notes
177
+ - Timestamps are printed on the reader's own clock. The API sends UTC, and
178
+ until 2026-09-16 the display lifted the digits out of the string, so a
179
+ capture taken at 18:09 in Tokyo was listed as 09:09. The `--date`, `--hour`
180
+ and `--today` filters were always local; only the display was wrong.
129
181
  - `-j, --json` is available on `config get`, `list`, `get`, `search`, `apps`, `domains`, `tags`, `locations`, and `summary`.
130
182
  - `summary` default output is Markdown with headings (`## Gyazo Summary`, `### YYYY-MM-DD`) and nested bullet lists.
131
183
  - There are no global `--plain` or `--verbose` flags in current implementation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuiseki/gyazocli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Gyazo Memory CLI for AI Secretary",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,6 +13,12 @@ questions about the same period are answered locally.
13
13
  Check `gyazo config get me` first when a token might be missing; it prints the
14
14
  account or exits non-zero. Set one with `gyazo config set token <token>`.
15
15
 
16
+ A 401 means Gyazo will not take the token that is there, which is a different
17
+ problem from not having one: it has been mistyped or revoked. Say so and stop,
18
+ rather than retrying or reaching for another command; every authenticated path
19
+ will answer the same way. `config set token` checks a token against the API
20
+ before saving it, so a bad one cannot quietly replace a working one.
21
+
16
22
  Every command exits non-zero on failure, so `&&` chains and `set -e` behave.
17
23
 
18
24
  ## Reading captures
@@ -62,6 +68,28 @@ Three things worth knowing before composing a query:
62
68
  - **There is no coordinate or radius search.** Search by place with `address:`,
63
69
  which matches the address in any language and matches postal codes too.
64
70
 
71
+ ## Going through captures one at a time
72
+
73
+ ```bash
74
+ gyazo triage -q "password" # markdown, one section per capture
75
+ gyazo triage -q "API key" --limit 50
76
+ gyazo triage --id <image_id> # revisit one, answered or not
77
+ gyazo triage -q "password" --no-interactive > review.md
78
+ ```
79
+
80
+ `triage` prints everything a capture carries, one `##` heading per field it
81
+ has, with the image ID as `#` and `---` between captures. At a terminal it
82
+ asks `Is it safe? [Y/n]` after each one and records the answer in
83
+ `${XDG_STATE_HOME:-~/.local/state}/gyazocli/triage.jsonl`; a capture already
84
+ answered is skipped next time, and the walk pages on to find ones that are not.
85
+
86
+ Answering `n` sets that capture to `only_me` when gyazo.com cookies are
87
+ available, because the public API cannot change an access policy after upload.
88
+
89
+ For an agent: pipe it (`--no-interactive`) and read the output. Do not answer
90
+ the prompts on the user's behalf. Whether a capture is safe to leave public is
91
+ theirs to decide, and `n` writes to their account.
92
+
65
93
  ## Summaries and rankings
66
94
 
67
95
  ```bash