@iflow-mcp/nazarlysyi-brickscope 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/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/cli/commands/config.js +81 -0
- package/dist/cli/commands/health.js +27 -0
- package/dist/cli/commands/identify.js +72 -0
- package/dist/cli/commands/mcp.js +14 -0
- package/dist/cli/commands/minifig.js +57 -0
- package/dist/cli/commands/part.js +59 -0
- package/dist/cli/commands/set.js +57 -0
- package/dist/cli/index.js +30 -0
- package/dist/cli/output.js +84 -0
- package/dist/core/brickognize/client.js +39 -0
- package/dist/core/brickognize/mappers.js +53 -0
- package/dist/core/brickognize/types.js +3 -0
- package/dist/core/cache/index.js +30 -0
- package/dist/core/cache/memory.js +15 -0
- package/dist/core/cache/sqlite.js +33 -0
- package/dist/core/cache/types.js +2 -0
- package/dist/core/concurrency.js +18 -0
- package/dist/core/config.js +43 -0
- package/dist/core/image.js +45 -0
- package/dist/core/rebrickable/client.js +91 -0
- package/dist/core/rebrickable/minifigDetails.js +26 -0
- package/dist/core/rebrickable/partDetails.js +80 -0
- package/dist/core/rebrickable/setDetails.js +47 -0
- package/dist/core/rebrickable/types.js +3 -0
- package/dist/core/utils/errors.js +51 -0
- package/dist/mcp/index.js +9 -0
- package/dist/mcp/server.js +56 -0
- package/dist/mcp/tools/batchPredict.js +55 -0
- package/dist/mcp/tools/cacheTools.js +24 -0
- package/dist/mcp/tools/health.js +19 -0
- package/dist/mcp/tools/minifigDetails.js +26 -0
- package/dist/mcp/tools/partDetails.js +83 -0
- package/dist/mcp/tools/predict.js +36 -0
- package/dist/mcp/tools/setDetails.js +30 -0
- package/dist/mcp/tools/shared.js +28 -0
- package/package.json +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brickognize MCP
|
|
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
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Brickscope
|
|
2
|
+
|
|
3
|
+
Identify LEGO parts, sets, and minifigures from images — as a **CLI tool** or an **MCP server** for AI assistants.
|
|
4
|
+
|
|
5
|
+
Powered by the [Brickognize API](https://api.brickognize.com/docs) and [Rebrickable](https://rebrickable.com/api/).
|
|
6
|
+
|
|
7
|
+
Huge thanks to [Piotr Rybak](https://brickognize.com/about) for creating the Brickognize service and making LEGO recognition accessible to everyone!
|
|
8
|
+
|
|
9
|
+
## CLI
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g brickscope
|
|
13
|
+
|
|
14
|
+
brickscope identify photo.jpg --type part
|
|
15
|
+
brickscope part 3001 --color Black
|
|
16
|
+
brickscope set 75192
|
|
17
|
+
brickscope minifig fig-012805
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or run without installing: `npx brickscope identify photo.jpg`
|
|
21
|
+
|
|
22
|
+
[Full CLI documentation](./docs/cli.md)
|
|
23
|
+
|
|
24
|
+
## MCP Server
|
|
25
|
+
|
|
26
|
+
For AI assistants (Claude, Cursor, etc.), add to your MCP config:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"brickscope": {
|
|
32
|
+
"command": "npx",
|
|
33
|
+
"args": ["-y", "brickscope", "mcp"],
|
|
34
|
+
"env": {
|
|
35
|
+
"REBRICKABLE_API_KEY": "your-key-here",
|
|
36
|
+
"BRICKOGNIZE_CACHE": "sqlite"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
[Full MCP documentation](./docs/mcp.md)
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
### Config file (CLI)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
brickscope config init
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Creates `~/.config/brickscope/config.json` with your Rebrickable API key and cache settings.
|
|
54
|
+
|
|
55
|
+
### Environment variables
|
|
56
|
+
|
|
57
|
+
| Variable | Default | Description |
|
|
58
|
+
| --------------------- | ------- | ------------------------------------------------------------------------------------------------- |
|
|
59
|
+
| `REBRICKABLE_API_KEY` | — | Free API key from [rebrickable.com/api](https://rebrickable.com/api/). Required for lookup tools. |
|
|
60
|
+
| `BRICKOGNIZE_CACHE` | `none` | Cache mode: `none`, `memory`, or `sqlite` |
|
|
61
|
+
|
|
62
|
+
Environment variables take priority over the config file.
|
|
63
|
+
|
|
64
|
+
## Features
|
|
65
|
+
|
|
66
|
+
- **Image recognition** — identify parts, sets, minifigures, and stickers from photos
|
|
67
|
+
- **Batch processing** — identify multiple images in parallel
|
|
68
|
+
- **Part lookup** — colors, set appearances via Rebrickable
|
|
69
|
+
- **Set inventory** — full parts list, year, theme, piece count
|
|
70
|
+
- **Minifigure lookup** — details and set appearances
|
|
71
|
+
- **Caching** — in-memory or SQLite cache for Rebrickable API responses
|
|
72
|
+
- **Config file** — save API key and preferences once, use everywhere
|
|
73
|
+
|
|
74
|
+
## Examples
|
|
75
|
+
|
|
76
|
+
See the [examples](./examples) folder for prompt templates.
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm install
|
|
82
|
+
npm run build
|
|
83
|
+
npm run dev # Watch mode
|
|
84
|
+
npm test # Unit + integration tests
|
|
85
|
+
npm run lint # ESLint
|
|
86
|
+
npm run format # Prettier
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { loadConfig, saveConfig, getConfigPath } from "../../core/config.js";
|
|
2
|
+
import * as readline from "node:readline/promises";
|
|
3
|
+
export function registerConfigCommand(program) {
|
|
4
|
+
const config = program.command("config").description("Manage brickscope configuration");
|
|
5
|
+
config
|
|
6
|
+
.command("show")
|
|
7
|
+
.description("Show current configuration")
|
|
8
|
+
.action(() => {
|
|
9
|
+
const cfg = loadConfig();
|
|
10
|
+
const configPath = getConfigPath();
|
|
11
|
+
console.log(`Config file: ${configPath}`);
|
|
12
|
+
console.log("");
|
|
13
|
+
if (Object.keys(cfg).length === 0) {
|
|
14
|
+
console.log("No configuration set. Run 'brickscope config init' to set up.");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (cfg.rebrickableApiKey) {
|
|
18
|
+
const masked = cfg.rebrickableApiKey.slice(0, 4) + "..." + cfg.rebrickableApiKey.slice(-4);
|
|
19
|
+
console.log(` rebrickableApiKey: ${masked}`);
|
|
20
|
+
}
|
|
21
|
+
if (cfg.cache) {
|
|
22
|
+
console.log(` cache: ${cfg.cache}`);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
config
|
|
26
|
+
.command("path")
|
|
27
|
+
.description("Print config file location")
|
|
28
|
+
.action(() => {
|
|
29
|
+
console.log(getConfigPath());
|
|
30
|
+
});
|
|
31
|
+
config
|
|
32
|
+
.command("set <key> <value>")
|
|
33
|
+
.description("Set a configuration value (rebrickableApiKey, cache)")
|
|
34
|
+
.action((key, value) => {
|
|
35
|
+
const cfg = loadConfig();
|
|
36
|
+
if (key === "rebrickableApiKey") {
|
|
37
|
+
cfg.rebrickableApiKey = value;
|
|
38
|
+
}
|
|
39
|
+
else if (key === "cache") {
|
|
40
|
+
if (!["none", "memory", "sqlite"].includes(value)) {
|
|
41
|
+
console.error(`Invalid cache value "${value}". Valid: none, memory, sqlite`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
cfg.cache = value;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
console.error(`Unknown config key "${key}". Valid keys: rebrickableApiKey, cache`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
saveConfig(cfg);
|
|
51
|
+
console.log(`Set ${key} = ${key === "rebrickableApiKey" ? "***" : value}`);
|
|
52
|
+
});
|
|
53
|
+
config
|
|
54
|
+
.command("init")
|
|
55
|
+
.description("Interactive setup wizard")
|
|
56
|
+
.action(async () => {
|
|
57
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
58
|
+
try {
|
|
59
|
+
const cfg = loadConfig();
|
|
60
|
+
const apiKey = await rl.question(`Rebrickable API key${cfg.rebrickableApiKey ? " (press Enter to keep current)" : ""}: `);
|
|
61
|
+
if (apiKey.trim()) {
|
|
62
|
+
cfg.rebrickableApiKey = apiKey.trim();
|
|
63
|
+
}
|
|
64
|
+
const cache = await rl.question(`Cache mode (none/memory/sqlite)${cfg.cache ? ` [${cfg.cache}]` : " [none]"}: `);
|
|
65
|
+
if (cache.trim()) {
|
|
66
|
+
if (!["none", "memory", "sqlite"].includes(cache.trim())) {
|
|
67
|
+
console.error(`Invalid cache value. Using "${cfg.cache ?? "none"}".`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
cfg.cache = cache.trim();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
saveConfig(cfg);
|
|
74
|
+
console.log(`\nConfiguration saved to ${getConfigPath()}`);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
rl.close();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { checkHealth } from "../../core/brickognize/client.js";
|
|
2
|
+
export function registerHealthCommand(program) {
|
|
3
|
+
program
|
|
4
|
+
.command("health")
|
|
5
|
+
.description("Check whether the Brickognize API is online")
|
|
6
|
+
.option("--json", "Output raw JSON")
|
|
7
|
+
.action(async (opts) => {
|
|
8
|
+
try {
|
|
9
|
+
const health = await checkHealth();
|
|
10
|
+
if (opts.json) {
|
|
11
|
+
console.log(JSON.stringify(health, null, 2));
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
const status = Object.values(health).every(Boolean) ? "healthy" : "degraded";
|
|
15
|
+
console.log(`Brickognize API: ${status}`);
|
|
16
|
+
for (const [key, value] of Object.entries(health)) {
|
|
17
|
+
console.log(` ${key}: ${value ? "ok" : "FAIL"}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
console.error(error instanceof Error ? error.message : "Failed to check health");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=health.js.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { predict } from "../../core/brickognize/client.js";
|
|
2
|
+
import { mapPredictionResult } from "../../core/brickognize/mappers.js";
|
|
3
|
+
import { resolveImage, PREDICT_ENDPOINTS } from "../../core/image.js";
|
|
4
|
+
import { runWithConcurrencyLimit } from "../../core/concurrency.js";
|
|
5
|
+
import { formatToolError } from "../../core/utils/errors.js";
|
|
6
|
+
import { formatPrediction } from "../output.js";
|
|
7
|
+
const CONCURRENCY_LIMIT = 5;
|
|
8
|
+
export function registerIdentifyCommand(program) {
|
|
9
|
+
program
|
|
10
|
+
.command("identify")
|
|
11
|
+
.description("Identify LEGO item(s) from photo(s)")
|
|
12
|
+
.argument("<images...>", "Path(s) to image files (JPEG, PNG, or WebP)")
|
|
13
|
+
.option("-t, --type <type>", "Item type: general, part, set, fig", "general")
|
|
14
|
+
.option("--json", "Output raw JSON")
|
|
15
|
+
.action(async (images, opts) => {
|
|
16
|
+
const type = opts.type;
|
|
17
|
+
const endpoint = PREDICT_ENDPOINTS[type];
|
|
18
|
+
if (!endpoint) {
|
|
19
|
+
console.error(`Unknown type "${opts.type}". Valid types: general, part, set, fig`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
if (images.length === 1) {
|
|
24
|
+
const { blob, filename } = await resolveImage({ imagePath: images[0] });
|
|
25
|
+
const raw = await predict(endpoint, blob, filename);
|
|
26
|
+
const result = mapPredictionResult(raw, false);
|
|
27
|
+
if (opts.json) {
|
|
28
|
+
console.log(JSON.stringify(result, null, 2));
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
console.log(formatPrediction(result));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const tasks = images.map((imagePath) => async () => {
|
|
36
|
+
try {
|
|
37
|
+
const { blob, filename } = await resolveImage({ imagePath });
|
|
38
|
+
const raw = await predict(endpoint, blob, filename);
|
|
39
|
+
const result = mapPredictionResult(raw, false);
|
|
40
|
+
return { imagePath, status: "success", result };
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
return { imagePath, status: "error", error: formatToolError(err) };
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
const results = await runWithConcurrencyLimit(tasks, CONCURRENCY_LIMIT);
|
|
47
|
+
if (opts.json) {
|
|
48
|
+
console.log(JSON.stringify(results, null, 2));
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
for (const r of results) {
|
|
52
|
+
console.log(`\n--- ${r.imagePath} ---`);
|
|
53
|
+
if (r.status === "success") {
|
|
54
|
+
console.log(formatPrediction(r.result));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.error(`Error: ${r.error}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const succeeded = results.filter((r) => r.status === "success").length;
|
|
61
|
+
const failed = results.length - succeeded;
|
|
62
|
+
console.log(`\nBatch: ${succeeded}/${results.length} succeeded${failed > 0 ? `, ${failed} failed` : ""}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
console.error(error instanceof Error ? error.message : "Identification failed");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=identify.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function registerMcpCommand(program) {
|
|
2
|
+
program
|
|
3
|
+
.command("mcp")
|
|
4
|
+
.description("Start MCP server (stdio transport) for AI assistant integration")
|
|
5
|
+
.action(async () => {
|
|
6
|
+
// Dynamic import to avoid loading MCP SDK unless needed
|
|
7
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
8
|
+
const { createServer } = await import("../../mcp/server.js");
|
|
9
|
+
const server = createServer();
|
|
10
|
+
const transport = new StdioServerTransport();
|
|
11
|
+
await server.connect(transport);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=mcp.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { fetchMinifigDetails } from "../../core/rebrickable/minifigDetails.js";
|
|
2
|
+
import { formatToolError } from "../../core/utils/errors.js";
|
|
3
|
+
import { formatMinifigDetails } from "../output.js";
|
|
4
|
+
export function registerMinifigCommand(program) {
|
|
5
|
+
program
|
|
6
|
+
.command("minifig")
|
|
7
|
+
.description("Get LEGO minifigure details and which sets contain it")
|
|
8
|
+
.argument("<ids...>", "Minifigure ID(s), e.g. fig-000001")
|
|
9
|
+
.option("--json", "Output raw JSON")
|
|
10
|
+
.action(async (ids, opts) => {
|
|
11
|
+
try {
|
|
12
|
+
if (ids.length === 1) {
|
|
13
|
+
const result = await fetchMinifigDetails(ids[0]);
|
|
14
|
+
if (opts.json) {
|
|
15
|
+
console.log(JSON.stringify(result, null, 2));
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
console.log(formatMinifigDetails(result));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const results = [];
|
|
23
|
+
for (const minifigId of ids) {
|
|
24
|
+
try {
|
|
25
|
+
const result = await fetchMinifigDetails(minifigId);
|
|
26
|
+
results.push({ minifigId, status: "success", result });
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
results.push({ minifigId, status: "error", error: formatToolError(err) });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (opts.json) {
|
|
33
|
+
console.log(JSON.stringify(results, null, 2));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
for (const r of results) {
|
|
37
|
+
console.log(`\n--- ${r.minifigId} ---`);
|
|
38
|
+
if (r.status === "success") {
|
|
39
|
+
console.log(formatMinifigDetails(r.result));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.error(`Error: ${r.error}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const succeeded = results.filter((r) => r.status === "success").length;
|
|
46
|
+
const failed = results.length - succeeded;
|
|
47
|
+
console.log(`\nBatch: ${succeeded}/${results.length} succeeded${failed > 0 ? `, ${failed} failed` : ""}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error(error instanceof Error ? error.message : "Failed to fetch minifig details");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=minifig.js.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { fetchPartDetails } from "../../core/rebrickable/partDetails.js";
|
|
2
|
+
import { formatToolError } from "../../core/utils/errors.js";
|
|
3
|
+
import { formatPartDetails } from "../output.js";
|
|
4
|
+
export function registerPartCommand(program) {
|
|
5
|
+
program
|
|
6
|
+
.command("part")
|
|
7
|
+
.description("Get details about LEGO part(s): colors, sets containing the part")
|
|
8
|
+
.argument("<ids...>", "LEGO part number(s), e.g. 3001")
|
|
9
|
+
.option("-c, --color <name>", "Filter by color name (e.g. Black)")
|
|
10
|
+
.option("--json", "Output raw JSON")
|
|
11
|
+
.action(async (ids, opts) => {
|
|
12
|
+
try {
|
|
13
|
+
if (ids.length === 1) {
|
|
14
|
+
const result = await fetchPartDetails(ids[0], opts.color);
|
|
15
|
+
if (opts.json) {
|
|
16
|
+
console.log(JSON.stringify(result, null, 2));
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
console.log(formatPartDetails(result));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
const results = [];
|
|
24
|
+
// Sequential due to Rebrickable rate limit
|
|
25
|
+
for (const partId of ids) {
|
|
26
|
+
try {
|
|
27
|
+
const result = await fetchPartDetails(partId, opts.color);
|
|
28
|
+
results.push({ partId, status: "success", result });
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
results.push({ partId, status: "error", error: formatToolError(err) });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (opts.json) {
|
|
35
|
+
console.log(JSON.stringify(results, null, 2));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
for (const r of results) {
|
|
39
|
+
console.log(`\n--- ${r.partId} ---`);
|
|
40
|
+
if (r.status === "success") {
|
|
41
|
+
console.log(formatPartDetails(r.result));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
console.error(`Error: ${r.error}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const succeeded = results.filter((r) => r.status === "success").length;
|
|
48
|
+
const failed = results.length - succeeded;
|
|
49
|
+
console.log(`\nBatch: ${succeeded}/${results.length} succeeded${failed > 0 ? `, ${failed} failed` : ""}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
console.error(error instanceof Error ? error.message : "Failed to fetch part details");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=part.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { fetchSetDetails } from "../../core/rebrickable/setDetails.js";
|
|
2
|
+
import { formatToolError } from "../../core/utils/errors.js";
|
|
3
|
+
import { formatSetDetails } from "../output.js";
|
|
4
|
+
export function registerSetCommand(program) {
|
|
5
|
+
program
|
|
6
|
+
.command("set")
|
|
7
|
+
.description("Get LEGO set details: inventory, year, theme, piece count")
|
|
8
|
+
.argument("<ids...>", 'Set number(s), e.g. 75192 (the "-1" suffix is added automatically)')
|
|
9
|
+
.option("--json", "Output raw JSON")
|
|
10
|
+
.action(async (ids, opts) => {
|
|
11
|
+
try {
|
|
12
|
+
if (ids.length === 1) {
|
|
13
|
+
const result = await fetchSetDetails(ids[0]);
|
|
14
|
+
if (opts.json) {
|
|
15
|
+
console.log(JSON.stringify(result, null, 2));
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
console.log(formatSetDetails(result));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const results = [];
|
|
23
|
+
for (const setId of ids) {
|
|
24
|
+
try {
|
|
25
|
+
const result = await fetchSetDetails(setId);
|
|
26
|
+
results.push({ setId, status: "success", result });
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
results.push({ setId, status: "error", error: formatToolError(err) });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (opts.json) {
|
|
33
|
+
console.log(JSON.stringify(results, null, 2));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
for (const r of results) {
|
|
37
|
+
console.log(`\n--- ${r.setId} ---`);
|
|
38
|
+
if (r.status === "success") {
|
|
39
|
+
console.log(formatSetDetails(r.result));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.error(`Error: ${r.error}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const succeeded = results.filter((r) => r.status === "success").length;
|
|
46
|
+
const failed = results.length - succeeded;
|
|
47
|
+
console.log(`\nBatch: ${succeeded}/${results.length} succeeded${failed > 0 ? `, ${failed} failed` : ""}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error(error instanceof Error ? error.message : "Failed to fetch set details");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=set.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { applyConfig } from "../core/config.js";
|
|
4
|
+
import { initCache } from "../core/cache/index.js";
|
|
5
|
+
import { setCache } from "../core/rebrickable/client.js";
|
|
6
|
+
import { registerHealthCommand } from "./commands/health.js";
|
|
7
|
+
import { registerIdentifyCommand } from "./commands/identify.js";
|
|
8
|
+
import { registerPartCommand } from "./commands/part.js";
|
|
9
|
+
import { registerSetCommand } from "./commands/set.js";
|
|
10
|
+
import { registerMinifigCommand } from "./commands/minifig.js";
|
|
11
|
+
import { registerMcpCommand } from "./commands/mcp.js";
|
|
12
|
+
import { registerConfigCommand } from "./commands/config.js";
|
|
13
|
+
// Load config file values (env vars take priority)
|
|
14
|
+
applyConfig();
|
|
15
|
+
// Initialize cache from resolved config
|
|
16
|
+
const cache = initCache();
|
|
17
|
+
setCache(cache);
|
|
18
|
+
const program = new Command()
|
|
19
|
+
.name("brickscope")
|
|
20
|
+
.description("Identify LEGO parts, sets, and minifigures from images")
|
|
21
|
+
.version("0.1.0");
|
|
22
|
+
registerHealthCommand(program);
|
|
23
|
+
registerIdentifyCommand(program);
|
|
24
|
+
registerPartCommand(program);
|
|
25
|
+
registerSetCommand(program);
|
|
26
|
+
registerMinifigCommand(program);
|
|
27
|
+
registerMcpCommand(program);
|
|
28
|
+
registerConfigCommand(program);
|
|
29
|
+
program.parse();
|
|
30
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export function formatPrediction(result) {
|
|
2
|
+
const lines = [];
|
|
3
|
+
if (result.matches.length === 0) {
|
|
4
|
+
lines.push("No matches found.");
|
|
5
|
+
return lines.join("\n");
|
|
6
|
+
}
|
|
7
|
+
for (const match of result.matches) {
|
|
8
|
+
const score = (match.score * 100).toFixed(1);
|
|
9
|
+
lines.push(` ${match.id} ${match.name} (${match.type}, ${score}%)`);
|
|
10
|
+
for (const site of match.externalSites) {
|
|
11
|
+
lines.push(` ${site.name}: ${site.url}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (result.predictedColors && result.predictedColors.length > 0) {
|
|
15
|
+
lines.push("");
|
|
16
|
+
lines.push("Predicted colors:");
|
|
17
|
+
for (const color of result.predictedColors) {
|
|
18
|
+
lines.push(` ${color.name} (${(color.score * 100).toFixed(1)}%)`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return lines.join("\n");
|
|
22
|
+
}
|
|
23
|
+
export function formatPartDetails(result) {
|
|
24
|
+
const lines = [];
|
|
25
|
+
lines.push(`Part ${result.part.partNum}: ${result.part.name}`);
|
|
26
|
+
lines.push(` URL: ${result.part.url}`);
|
|
27
|
+
lines.push(` Colors: ${result.totalColors}, Set appearances: ~${result.totalSetsAppearances}`);
|
|
28
|
+
if (result.colorFilter) {
|
|
29
|
+
if (result.colorMatched) {
|
|
30
|
+
lines.push(` Filtered by color: "${result.colorFilter}"`);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
lines.push(` Color "${result.colorFilter}" not found, showing top 5 colors`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
lines.push("");
|
|
37
|
+
for (const color of result.colorDetails) {
|
|
38
|
+
lines.push(` ${color.colorName} (${color.numSets} sets):`);
|
|
39
|
+
for (const set of color.sets.slice(0, 10)) {
|
|
40
|
+
lines.push(` ${set.setNum} ${set.setName} (${set.year}, ${set.numParts} pcs)`);
|
|
41
|
+
}
|
|
42
|
+
if (color.sets.length > 10) {
|
|
43
|
+
lines.push(` ... and ${color.sets.length - 10} more`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
|
48
|
+
export function formatSetDetails(result) {
|
|
49
|
+
const lines = [];
|
|
50
|
+
lines.push(`Set ${result.set.setNum}: ${result.set.name} (${result.set.year})`);
|
|
51
|
+
lines.push(` Pieces: ${result.set.numParts}, Unique parts: ${result.parts.length}, Spare: ${result.spareParts.length}`);
|
|
52
|
+
lines.push(` URL: ${result.set.url}`);
|
|
53
|
+
lines.push("");
|
|
54
|
+
lines.push("Parts:");
|
|
55
|
+
for (const part of result.parts) {
|
|
56
|
+
lines.push(` ${part.quantity}x ${part.partNum} ${part.name} [${part.color}]`);
|
|
57
|
+
}
|
|
58
|
+
if (result.spareParts.length > 0) {
|
|
59
|
+
lines.push("");
|
|
60
|
+
lines.push("Spare parts:");
|
|
61
|
+
for (const part of result.spareParts) {
|
|
62
|
+
lines.push(` ${part.quantity}x ${part.partNum} ${part.name} [${part.color}]`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
export function formatMinifigDetails(result) {
|
|
68
|
+
const lines = [];
|
|
69
|
+
lines.push(`Minifigure ${result.minifig.id}: ${result.minifig.name}`);
|
|
70
|
+
lines.push(` Parts: ${result.minifig.numParts}`);
|
|
71
|
+
lines.push(` URL: ${result.minifig.url}`);
|
|
72
|
+
lines.push("");
|
|
73
|
+
if (result.appearsInSets.length > 0) {
|
|
74
|
+
lines.push(`Appears in ${result.appearsInSets.length} set(s):`);
|
|
75
|
+
for (const set of result.appearsInSets) {
|
|
76
|
+
lines.push(` ${set.setNum} ${set.setName} (${set.numParts} pcs)`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
lines.push("Does not appear in any sets.");
|
|
81
|
+
}
|
|
82
|
+
return lines.join("\n");
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=output.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { apiError, unexpectedResponse } from "../utils/errors.js";
|
|
2
|
+
const PARTS_PREDICT_ENDPOINT = "/predict/parts/";
|
|
3
|
+
const BASE_URL = "https://api.brickognize.com";
|
|
4
|
+
export async function checkHealth() {
|
|
5
|
+
const res = await fetch(`${BASE_URL}/health/`, {
|
|
6
|
+
signal: AbortSignal.timeout(10_000),
|
|
7
|
+
});
|
|
8
|
+
if (!res.ok) {
|
|
9
|
+
throw apiError(res.status, await res.text());
|
|
10
|
+
}
|
|
11
|
+
const data = await res.json();
|
|
12
|
+
if (typeof data !== "object" || data === null) {
|
|
13
|
+
throw unexpectedResponse("health endpoint did not return an object");
|
|
14
|
+
}
|
|
15
|
+
return data;
|
|
16
|
+
}
|
|
17
|
+
export async function predict(endpoint, imageBlob, filename) {
|
|
18
|
+
const form = new FormData();
|
|
19
|
+
form.append("query_image", imageBlob, filename);
|
|
20
|
+
// Always request color prediction for the parts endpoint specifically
|
|
21
|
+
const url = endpoint === PARTS_PREDICT_ENDPOINT
|
|
22
|
+
? `${BASE_URL}${endpoint}?predict_color=true`
|
|
23
|
+
: `${BASE_URL}${endpoint}`;
|
|
24
|
+
const res = await fetch(url, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
body: form,
|
|
27
|
+
signal: AbortSignal.timeout(60_000),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const body = await res.text();
|
|
31
|
+
throw apiError(res.status, body);
|
|
32
|
+
}
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
if (!data || typeof data.listing_id !== "string" || !Array.isArray(data.items)) {
|
|
35
|
+
throw unexpectedResponse("missing listing_id or items array");
|
|
36
|
+
}
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=client.js.map
|