highspot-cli 0.1.1 → 0.2.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
@@ -72,8 +72,7 @@ Example `.highspot-cli.json`:
72
72
 
73
73
  ```bash
74
74
  highspot search <query>
75
- highspot item <item-id>
76
- highspot content <item-id>
75
+ highspot get <item-id>
77
76
  highspot me
78
77
  ```
79
78
 
@@ -94,6 +93,15 @@ Global flags:
94
93
  - `--no-input`
95
94
  - `--no-color`
96
95
 
96
+ `get` command flags:
97
+
98
+ - `--format <value>`
99
+ - `--start <value>`
100
+ - `--meta-only` (skip content download)
101
+ - `-o, --output <path>` (explicit file path)
102
+ - `--output-dir <path>` (directory for auto-saved binary files)
103
+ - `-f, --force` (overwrite existing output file)
104
+
97
105
  Exit codes:
98
106
 
99
107
  - `0` success
@@ -105,8 +113,11 @@ Exit codes:
105
113
  ```bash
106
114
  highspot search "GoGuardian Teacher" --limit 10
107
115
  highspot search "Beacon" --sort-by date_added --plain
108
- highspot item it_abc123
109
- highspot content it_abc123 --format text/plain --plain
116
+ highspot get it_abc123 --meta-only
117
+ highspot get it_abc123 --format text/plain --plain
118
+ highspot get it_abc123
119
+ highspot get it_abc123 --output ./custom-filename.pdf
120
+ highspot get it_abc123 --output-dir ./downloads
110
121
  highspot me --json
111
122
  highspot search "Fleet" --dry-run
112
123
  ```
@@ -115,6 +126,9 @@ Behavior notes:
115
126
 
116
127
  - Prompts are not used; `--no-input` is accepted for automation consistency.
117
128
  - Primary data goes to stdout, errors go to stderr.
129
+ - `get` always fetches `/items/{id}` metadata first, then fetches `/items/{id}/content` unless `--meta-only` is set.
130
+ - Binary content is automatically saved to disk using Highspot `content_name` (canonical filename) when available.
131
+ - Use `--output` to force a specific filename/path, or `--output-dir` to control where auto-saved binaries are written.
118
132
 
119
133
  ## Development
120
134
 
@@ -0,0 +1,263 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { dirname, extname, join, resolve } from "node:path";
5
+ import { Args } from "@oclif/core";
6
+ import { BaseCommand } from "../lib/command.js";
7
+ import { contentFlags, globalFlags } from "../lib/flags.js";
8
+ export default class Get extends BaseCommand {
9
+ static description = "Fetch item metadata and content in one command";
10
+ static examples = [
11
+ "highspot get it_abc123",
12
+ "highspot get it_abc123 --meta-only",
13
+ "highspot get it_abc123 --output ./discover-guide.pdf",
14
+ "highspot get it_abc123 --output-dir ./downloads",
15
+ "highspot get it_abc123 --format text/plain --plain",
16
+ ];
17
+ static args = {
18
+ itemId: Args.string({
19
+ description: "Highspot item id",
20
+ required: false,
21
+ }),
22
+ };
23
+ static flags = {
24
+ ...globalFlags,
25
+ ...contentFlags,
26
+ };
27
+ async run() {
28
+ const { args, flags } = await this.parse(Get);
29
+ this.ensureOutputFlags(flags);
30
+ this.ensureVerbosityFlags(flags);
31
+ if (!args.itemId) {
32
+ this.fail("itemId is required", 2, flags);
33
+ }
34
+ const payload = {
35
+ operations: [
36
+ { endpoint: `/items/${encodeURIComponent(args.itemId)}` },
37
+ ...(flags["meta-only"]
38
+ ? []
39
+ : [
40
+ {
41
+ endpoint: `/items/${encodeURIComponent(args.itemId)}/content`,
42
+ query: {
43
+ ...(flags.format ? { format: flags.format } : {}),
44
+ ...(flags.start ? { start: flags.start } : {}),
45
+ },
46
+ },
47
+ ]),
48
+ ],
49
+ output: {
50
+ ...(flags.output ? { output: flags.output } : {}),
51
+ ...(flags["output-dir"] ? { outputDir: flags["output-dir"] } : {}),
52
+ force: flags.force,
53
+ },
54
+ headers: {
55
+ ...(flags["hs-user"] ? { "hs-user": flags["hs-user"] } : {}),
56
+ },
57
+ };
58
+ if (flags["dry-run"]) {
59
+ this.printJson(payload);
60
+ return;
61
+ }
62
+ try {
63
+ const client = this.clientFromFlags(flags);
64
+ const itemData = await client.getItem({
65
+ itemId: args.itemId,
66
+ hsUser: this.effectiveHsUser(flags),
67
+ });
68
+ const item = asItemRecord(itemData.item);
69
+ if (flags["meta-only"]) {
70
+ if (this.outputMode(flags) === "plain") {
71
+ writeItemPlain(item);
72
+ return;
73
+ }
74
+ this.printJson({ item });
75
+ return;
76
+ }
77
+ const contentData = await client.getItemContent({
78
+ itemId: args.itemId,
79
+ format: flags.format,
80
+ start: flags.start,
81
+ hsUser: this.effectiveHsUser(flags),
82
+ });
83
+ const explicitOutputPath = flags.output
84
+ ? resolve(process.cwd(), flags.output)
85
+ : undefined;
86
+ if (contentData.kind === "binary") {
87
+ const binaryOutputPath = explicitOutputPath ??
88
+ resolveBinaryOutputPath({
89
+ outputDir: flags["output-dir"],
90
+ item,
91
+ itemId: args.itemId,
92
+ contentType: contentData.contentType,
93
+ });
94
+ const fileResult = await writeBytesToFile({
95
+ bytes: contentData.bytes,
96
+ contentType: contentData.contentType,
97
+ force: flags.force,
98
+ item,
99
+ itemId: args.itemId,
100
+ outputPath: binaryOutputPath,
101
+ });
102
+ if (this.outputMode(flags) === "plain") {
103
+ writeFileResultPlain(fileResult);
104
+ return;
105
+ }
106
+ this.printJson(fileResult);
107
+ return;
108
+ }
109
+ if (explicitOutputPath) {
110
+ const fileResult = await writeInlineContentToFile({
111
+ content: contentData,
112
+ force: flags.force,
113
+ item,
114
+ itemId: args.itemId,
115
+ outputPath: explicitOutputPath,
116
+ });
117
+ if (this.outputMode(flags) === "plain") {
118
+ writeFileResultPlain(fileResult);
119
+ return;
120
+ }
121
+ this.printJson(fileResult);
122
+ return;
123
+ }
124
+ if (this.outputMode(flags) === "plain") {
125
+ writeInlineContentPlain(contentData);
126
+ return;
127
+ }
128
+ this.printJson({
129
+ item,
130
+ mode: "inline",
131
+ isBinary: false,
132
+ contentType: contentData.contentType,
133
+ content: contentData.content,
134
+ });
135
+ }
136
+ catch (error) {
137
+ this.handleError(error, flags);
138
+ }
139
+ }
140
+ }
141
+ function asItemRecord(value) {
142
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
143
+ return {};
144
+ }
145
+ return value;
146
+ }
147
+ function writeItemPlain(item) {
148
+ const id = asString(item.id);
149
+ const url = asString(item.url);
150
+ const title = asString(item.title);
151
+ const contentType = asString(item.content_type);
152
+ const contentName = asString(item.content_name);
153
+ process.stdout.write(`${id}\t${url}\t${title}\t${contentType}\t${contentName}\n`);
154
+ }
155
+ function writeInlineContentPlain(content) {
156
+ if (content.kind === "text") {
157
+ process.stdout.write(content.content);
158
+ if (!content.content.endsWith("\n")) {
159
+ process.stdout.write("\n");
160
+ }
161
+ return;
162
+ }
163
+ if (content.kind === "json") {
164
+ process.stdout.write(`${JSON.stringify(content.content, null, 2)}\n`);
165
+ return;
166
+ }
167
+ throw new Error("Binary content must be written to a file.");
168
+ }
169
+ function writeFileResultPlain(result) {
170
+ process.stdout.write(`${result.itemId}\t${result.outputPath}\t${result.contentType}\t${result.sizeBytes}\t${result.sha256}\n`);
171
+ }
172
+ function resolveBinaryOutputPath(args) {
173
+ const outputDir = args.outputDir
174
+ ? resolve(process.cwd(), args.outputDir)
175
+ : process.cwd();
176
+ const fileName = preferredBinaryFileName(args.item, args.itemId, args.contentType);
177
+ return join(outputDir, fileName);
178
+ }
179
+ function preferredBinaryFileName(item, itemId, contentType) {
180
+ const candidates = [
181
+ item.content_name,
182
+ item.filename,
183
+ item.file_name,
184
+ item.name,
185
+ item.title,
186
+ ]
187
+ .map((value) => asString(value).trim())
188
+ .filter((value) => value.length > 0);
189
+ const baseCandidate = candidates[0] ?? itemId;
190
+ const safeBase = sanitizeFileName(baseCandidate) || itemId;
191
+ if (extname(safeBase)) {
192
+ return safeBase;
193
+ }
194
+ const extension = extensionFromContentType(contentType) ?? "bin";
195
+ return `${safeBase}.${extension}`;
196
+ }
197
+ function sanitizeFileName(input) {
198
+ const invalidChars = new Set(["\\", "/", ":", "*", "?", '"', "<", ">", "|"]);
199
+ let output = "";
200
+ for (const char of input) {
201
+ const code = char.charCodeAt(0);
202
+ if (code >= 0 && code < 32) {
203
+ output += "_";
204
+ continue;
205
+ }
206
+ output += invalidChars.has(char) ? "_" : char;
207
+ }
208
+ return output.replace(/\s+/g, " ").trim();
209
+ }
210
+ function extensionFromContentType(contentType) {
211
+ const normalized = contentType.toLowerCase();
212
+ if (normalized.includes("pdf")) {
213
+ return "pdf";
214
+ }
215
+ if (normalized.includes("json")) {
216
+ return "json";
217
+ }
218
+ if (normalized.includes("csv")) {
219
+ return "csv";
220
+ }
221
+ if (normalized.startsWith("text/")) {
222
+ return "txt";
223
+ }
224
+ return undefined;
225
+ }
226
+ async function writeBytesToFile(args) {
227
+ await ensureWriteablePath(args.outputPath, args.force);
228
+ await writeFile(args.outputPath, args.bytes);
229
+ return {
230
+ contentType: args.contentType,
231
+ item: args.item,
232
+ itemId: args.itemId,
233
+ mode: "file",
234
+ outputPath: args.outputPath,
235
+ sha256: createHash("sha256").update(args.bytes).digest("hex"),
236
+ sizeBytes: args.bytes.byteLength,
237
+ };
238
+ }
239
+ async function writeInlineContentToFile(args) {
240
+ await ensureWriteablePath(args.outputPath, args.force);
241
+ const text = args.content.kind === "text"
242
+ ? args.content.content
243
+ : `${JSON.stringify(args.content.content, null, 2)}\n`;
244
+ await writeFile(args.outputPath, text, "utf8");
245
+ return {
246
+ contentType: args.content.contentType,
247
+ item: args.item,
248
+ itemId: args.itemId,
249
+ mode: "file",
250
+ outputPath: args.outputPath,
251
+ sha256: createHash("sha256").update(text, "utf8").digest("hex"),
252
+ sizeBytes: Buffer.byteLength(text, "utf8"),
253
+ };
254
+ }
255
+ async function ensureWriteablePath(path, force) {
256
+ await mkdir(dirname(path), { recursive: true });
257
+ if (existsSync(path) && !force) {
258
+ throw new Error(`Output file already exists: ${path}. Use --force to overwrite.`);
259
+ }
260
+ }
261
+ function asString(value) {
262
+ return typeof value === "string" ? value : "";
263
+ }
package/dist/lib/api.js CHANGED
@@ -160,8 +160,8 @@ export class HighspotClient {
160
160
  if (contentType.includes("json")) {
161
161
  const payload = (await response.json());
162
162
  return {
163
+ kind: "json",
163
164
  content: payload,
164
- contentEncoding: "json",
165
165
  contentType,
166
166
  isBinary: false,
167
167
  isJson: true,
@@ -171,8 +171,8 @@ export class HighspotClient {
171
171
  if (!looksTextual(contentType)) {
172
172
  const binary = await response.arrayBuffer();
173
173
  return {
174
- content: Buffer.from(binary).toString("base64"),
175
- contentEncoding: "base64",
174
+ kind: "binary",
175
+ bytes: new Uint8Array(binary),
176
176
  contentLength: binary.byteLength,
177
177
  contentType,
178
178
  isBinary: true,
@@ -182,8 +182,8 @@ export class HighspotClient {
182
182
  }
183
183
  const textContent = await response.text();
184
184
  return {
185
+ kind: "text",
185
186
  content: textContent,
186
- contentEncoding: "utf8",
187
187
  contentLength: textContent.length,
188
188
  contentType,
189
189
  isBinary: false,
@@ -43,6 +43,9 @@ export class BaseCommand extends Command {
43
43
  this.exit(exitCode);
44
44
  }
45
45
  handleError(error, flags) {
46
+ if (error instanceof Error && /^EEXIT:\s*\d+/.test(error.message)) {
47
+ throw error;
48
+ }
46
49
  const mode = this.outputMode(flags);
47
50
  if (error instanceof ApiError) {
48
51
  writeError(mode, formatApiError(error));
package/dist/lib/flags.js CHANGED
@@ -72,4 +72,20 @@ export const contentFlags = {
72
72
  start: Flags.string({
73
73
  description: "Optional cursor/start token for paginated content",
74
74
  }),
75
+ output: Flags.string({
76
+ char: "o",
77
+ description: "Write content body to a specific file path",
78
+ }),
79
+ "output-dir": Flags.string({
80
+ description: "Directory for auto-saved binary content (default: current working directory)",
81
+ }),
82
+ "meta-only": Flags.boolean({
83
+ description: "Return item metadata only (skip content download)",
84
+ default: false,
85
+ }),
86
+ force: Flags.boolean({
87
+ char: "f",
88
+ description: "Allow overwriting an existing output file",
89
+ default: false,
90
+ }),
75
91
  };
package/dist/lib/help.js CHANGED
@@ -9,8 +9,8 @@ export default class Help extends OclifHelp {
9
9
  const examples = this.section("EXAMPLES", this.renderList([
10
10
  ["highspot --help"],
11
11
  ['highspot search "GoGuardian Teacher" --limit 5'],
12
- ["highspot item it_abc123 --plain"],
13
- ["highspot content it_abc123 --format text/plain --plain"],
12
+ ["highspot get it_abc123 --meta-only"],
13
+ ["highspot get it_abc123 --output ./discover-guide.pdf"],
14
14
  ["highspot me --json"],
15
15
  ], { indentation: 2, spacer: "\n", stripAnsi: this.opts.stripAnsi }));
16
16
  return `${base}\n\n${auth}\n\n${config}\n\n${examples}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "highspot-cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-first CLI for the Highspot API",
5
5
  "license": "MIT",
6
6
  "author": "Advait Shinde",
@@ -18,7 +18,7 @@
18
18
  "highspot-cli": "dist/bin/highspot.js"
19
19
  },
20
20
  "scripts": {
21
- "build": "tsc -p tsconfig.build.json",
21
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
22
22
  "dev": "npm run build && node dist/bin/highspot.js",
23
23
  "lint": "biome check .",
24
24
  "format": "biome format --write .",
@@ -1,70 +0,0 @@
1
- import { Args } from "@oclif/core";
2
- import { BaseCommand } from "../lib/command.js";
3
- import { contentFlags, globalFlags } from "../lib/flags.js";
4
- export default class Content extends BaseCommand {
5
- static description = "Fetch Highspot item content";
6
- static examples = [
7
- "highspot content it_abc123",
8
- "highspot content it_abc123 --format text/plain --plain",
9
- "highspot content it_abc123 --start cursor-2",
10
- ];
11
- static args = {
12
- itemId: Args.string({
13
- description: "Highspot item id",
14
- required: false,
15
- }),
16
- };
17
- static flags = {
18
- ...globalFlags,
19
- ...contentFlags,
20
- };
21
- async run() {
22
- const { args, flags } = await this.parse(Content);
23
- this.ensureOutputFlags(flags);
24
- this.ensureVerbosityFlags(flags);
25
- if (!args.itemId) {
26
- this.fail("itemId is required", 2, flags);
27
- }
28
- const payload = {
29
- endpoint: `/items/${encodeURIComponent(args.itemId)}/content`,
30
- query: {
31
- ...(flags.format ? { format: flags.format } : {}),
32
- ...(flags.start ? { start: flags.start } : {}),
33
- },
34
- headers: {
35
- ...(flags["hs-user"] ? { "hs-user": flags["hs-user"] } : {}),
36
- },
37
- };
38
- if (flags["dry-run"]) {
39
- this.printJson(payload);
40
- return;
41
- }
42
- try {
43
- const client = this.clientFromFlags(flags);
44
- const data = await client.getItemContent({
45
- itemId: args.itemId,
46
- format: flags.format,
47
- start: flags.start,
48
- hsUser: this.effectiveHsUser(flags),
49
- });
50
- if (this.outputMode(flags) === "plain") {
51
- writeContentPlain(data.content);
52
- return;
53
- }
54
- this.printJson(data);
55
- }
56
- catch (error) {
57
- this.handleError(error, flags);
58
- }
59
- }
60
- }
61
- function writeContentPlain(content) {
62
- if (typeof content === "string") {
63
- process.stdout.write(content);
64
- if (!content.endsWith("\n")) {
65
- process.stdout.write("\n");
66
- }
67
- return;
68
- }
69
- process.stdout.write(`${JSON.stringify(content, null, 2)}\n`);
70
- }
@@ -1,66 +0,0 @@
1
- import { Args } from "@oclif/core";
2
- import { BaseCommand } from "../lib/command.js";
3
- import { globalFlags } from "../lib/flags.js";
4
- export default class Item extends BaseCommand {
5
- static description = "Fetch metadata for a Highspot item";
6
- static examples = [
7
- "highspot item it_abc123",
8
- "highspot item it_abc123 --hs-user user@example.com",
9
- "highspot item it_abc123 --plain",
10
- ];
11
- static args = {
12
- itemId: Args.string({
13
- description: "Highspot item id",
14
- required: false,
15
- }),
16
- };
17
- static flags = {
18
- ...globalFlags,
19
- };
20
- async run() {
21
- const { args, flags } = await this.parse(Item);
22
- this.ensureOutputFlags(flags);
23
- this.ensureVerbosityFlags(flags);
24
- if (!args.itemId) {
25
- this.fail("itemId is required", 2, flags);
26
- }
27
- const payload = {
28
- endpoint: `/items/${encodeURIComponent(args.itemId)}`,
29
- headers: {
30
- ...(flags["hs-user"] ? { "hs-user": flags["hs-user"] } : {}),
31
- },
32
- };
33
- if (flags["dry-run"]) {
34
- this.printJson(payload);
35
- return;
36
- }
37
- try {
38
- const client = this.clientFromFlags(flags);
39
- const data = await client.getItem({
40
- itemId: args.itemId,
41
- hsUser: this.effectiveHsUser(flags),
42
- });
43
- if (this.outputMode(flags) === "plain") {
44
- writeItemPlain(data.item);
45
- return;
46
- }
47
- this.printJson(data);
48
- }
49
- catch (error) {
50
- this.handleError(error, flags);
51
- }
52
- }
53
- }
54
- function writeItemPlain(item) {
55
- if (typeof item !== "object" || item === null || Array.isArray(item)) {
56
- process.stdout.write(`${JSON.stringify(item)}\n`);
57
- return;
58
- }
59
- const record = item;
60
- const id = typeof record.id === "string" ? record.id : "";
61
- const title = typeof record.title === "string" ? record.title : "";
62
- const url = typeof record.url === "string" ? record.url : "";
63
- const contentType = typeof record.content_type === "string" ? record.content_type : "";
64
- const description = typeof record.description === "string" ? record.description : "";
65
- process.stdout.write(`${id}\t${url}\t${title}\t${contentType}\t${description}\n`);
66
- }