mantisai-cli 3.0.0 → 3.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/README.md CHANGED
@@ -10,6 +10,7 @@ Full docs (VitePress):
10
10
  - [Install](https://mantis.csail.mit.edu/docs/mantis-cli/install)
11
11
  - [Claude Code](https://mantis.csail.mit.edu/docs/mantis-cli/claude-code)
12
12
  - [OpenCode](https://mantis.csail.mit.edu/docs/mantis-cli/opencode)
13
+ - [Codex](https://mantis.csail.mit.edu/docs/mantis-cli/codex)
13
14
 
14
15
  Source: `Mantis/docs/mantis-cli/`
15
16
 
@@ -33,7 +34,7 @@ Config: `~/.mantis/config.json`
33
34
 
34
35
  | Command | Description |
35
36
  | --- | --- |
36
- | `mantis setup [claude\|opencode]` | API + space/thread, or sync editor skills |
37
+ | `mantis setup [claude\|opencode\|codex]` | API + space/thread, or sync editor skills |
37
38
  | `mantis status` | Current config |
38
39
  | `mantis select [space\|thread\|both]` | Switch space/thread |
39
40
  | `mantis spaces list\|resolve\|set` | Scriptable space ops (JSON) |
package/bin/mantis.js CHANGED
@@ -104,7 +104,7 @@ program
104
104
 
105
105
  .description('Mantis CLI — spaces, maps, and MCP tools for AI agents')
106
106
 
107
- .version('3.0.0');
107
+ .version('3.1.0');
108
108
 
109
109
 
110
110
 
@@ -112,15 +112,17 @@ program
112
112
 
113
113
  .command('setup [provider]')
114
114
 
115
- .description('Configure Mantis, or install skills for claude/opencode')
115
+ .description('Configure Mantis, or install skills for claude/opencode/codex')
116
116
 
117
- .action(async (provider) => {
117
+ .option('--project', 'Also sync repo-scoped skills (Codex: ./.agents/skills/)')
118
+
119
+ .action(async (provider, opts) => {
118
120
 
119
121
  try {
120
122
 
121
123
  if (!provider) return setup.run();
122
124
 
123
- setup.runProvider(provider);
125
+ setup.runProvider(provider, { project: !!opts.project });
124
126
 
125
127
  } catch (e) {
126
128
 
@@ -368,6 +370,8 @@ program
368
370
 
369
371
  .allowUnknownOption()
370
372
 
373
+ .allowExcessArguments()
374
+
371
375
  .action(async () => {
372
376
 
373
377
  try {
package/lib/constants.js CHANGED
@@ -10,4 +10,17 @@ export const DISABLED_MCP_TOOLS = new Set([
10
10
  'create_space',
11
11
  'create_map_from_url',
12
12
  'modify_map_from_url',
13
+ // sandbox-bound: require an X-Chat-ID + live AgentSession container that the
14
+ // CLI has no way to provide. only usable by in-sandbox cc/oc agents.
15
+ 'export',
16
+ 'cite_file',
17
+ // notebook cell tools: hidden from cc/oc on the API side and not intended
18
+ // for agent/CLI use — cells round-trip through the user's notebook UI.
19
+ 'add_cell',
20
+ 'edit_cell',
21
+ 'delete_cell',
22
+ 'execute_cell',
23
+ 'get_cell',
24
+ 'get_cell_count',
25
+ 'check_task_status',
13
26
  ]);
package/lib/container.js CHANGED
@@ -7,12 +7,14 @@ import { FastGlobCodebaseIndexer } from './impl/fast-glob-codebase-indexer.js';
7
7
  import { FsCsvReader } from './impl/fs-csv-reader.js';
8
8
  import { ClaudeSkillsService } from './impl/claude-skills-service.js';
9
9
  import { OpencodeSkillsService } from './impl/opencode-skills-service.js';
10
+ import { CodexSkillsService } from './impl/codex-skills-service.js';
10
11
  import { SelectionService } from './services/selection-service.js';
11
12
  import { SetupService } from './services/setup-service.js';
12
13
  import { MapService } from './services/map-service.js';
13
14
  import { QueryService } from './services/query-service.js';
14
15
  import { ContextService } from './services/context-service.js';
15
16
  import { ToolService } from './services/tool-service.js';
17
+ import { ExportService } from './services/export-service.js';
16
18
 
17
19
  export function createContainer(overrides = {}) {
18
20
  const configStore = overrides.configStore ?? new FileConfigStore();
@@ -24,13 +26,15 @@ export function createContainer(overrides = {}) {
24
26
  const csvReader = overrides.csvReader ?? new FsCsvReader();
25
27
  const claudeSkills = overrides.claudeSkills ?? new ClaudeSkillsService();
26
28
  const opencodeSkills = overrides.opencodeSkills ?? new OpencodeSkillsService();
29
+ const codexSkills = overrides.codexSkills ?? new CodexSkillsService();
27
30
 
28
31
  const selection = new SelectionService({ configStore, spaces, client, ui });
29
- const setup = new SetupService({ configStore, selection, claudeSkills, opencodeSkills, ui });
32
+ const setup = new SetupService({ configStore, selection, claudeSkills, opencodeSkills, codexSkills, ui });
30
33
  const map = new MapService({ configStore, client, spaces, csvReader, ui });
31
34
  const query = new QueryService({ configStore, spaces });
32
35
  const context = new ContextService({ configStore });
33
- const tools = new ToolService({ mcp });
36
+ const exporter = new ExportService({ configStore, client });
37
+ const tools = new ToolService({ mcp, exporter });
34
38
 
35
39
  return {
36
40
  configStore,
@@ -42,12 +46,14 @@ export function createContainer(overrides = {}) {
42
46
  csvReader,
43
47
  claudeSkills,
44
48
  opencodeSkills,
49
+ codexSkills,
45
50
  selection,
46
51
  setup,
47
52
  map,
48
53
  query,
49
54
  context,
50
55
  tools,
56
+ exporter,
51
57
  };
52
58
  }
53
59
 
@@ -0,0 +1,19 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ import { SKILLS_DIR } from '../utils/package-root.js';
5
+ import { syncSkills } from '../utils/skills-sync.js';
6
+
7
+ export class CodexSkillsService {
8
+ sync(cwd = process.cwd(), { project = false } = {}) {
9
+ const globalSkillsDir = path.join(os.homedir(), '.agents', 'skills');
10
+ const targets = [globalSkillsDir];
11
+ let projectSkillsDir;
12
+ if (project) {
13
+ projectSkillsDir = path.join(cwd, '.agents', 'skills');
14
+ targets.push(projectSkillsDir);
15
+ }
16
+ const installed = syncSkills({ skillsDir: SKILLS_DIR, targets });
17
+ return { globalSkillsDir, projectSkillsDir, installed };
18
+ }
19
+ }
@@ -75,6 +75,49 @@ export class HttpMantisClient {
75
75
  });
76
76
  }
77
77
 
78
+ /**
79
+ * Export the rows behind a Mantis URI as parquet bytes.
80
+ * Returns { data: Buffer, rows, fields } on success.
81
+ * Throws on HTTP error, surfacing the server's structured error body.
82
+ */
83
+ async exportUri({ uri, spaceStateId, fields, includeEmbedding = false }) {
84
+ const { apiBaseUrl, apiKey } = this._credentials();
85
+ if (!spaceStateId) {
86
+ throw new Error('No thread configured. Run: mantis setup or mantis select thread');
87
+ }
88
+ const root = normalizeBaseUrl(apiBaseUrl);
89
+ const url = new URL('/api/v1/me/export/', `${root}/`);
90
+ const body = { uri, space_state_id: String(spaceStateId), include_embedding: Boolean(includeEmbedding) };
91
+ if (fields && fields.length) body.fields = fields;
92
+
93
+ const res = await fetch(url, {
94
+ method: 'POST',
95
+ headers: {
96
+ Authorization: `Bearer ${apiKey}`,
97
+ 'Content-Type': 'application/json',
98
+ Accept: 'application/vnd.apache.parquet, application/json',
99
+ },
100
+ body: JSON.stringify(body),
101
+ });
102
+
103
+ if (!res.ok) {
104
+ // error responses are JSON; reuse the shared formatter.
105
+ const text = await res.text();
106
+ let data;
107
+ try {
108
+ data = text ? JSON.parse(text) : {};
109
+ } catch {
110
+ data = { error: text || res.statusText };
111
+ }
112
+ throw new Error(formatApiError(data, res));
113
+ }
114
+
115
+ const buf = Buffer.from(await res.arrayBuffer());
116
+ const rows = Number(res.headers.get('x-mantis-rows')) || 0;
117
+ const fieldsHeader = res.headers.get('x-mantis-fields') || '';
118
+ return { data: buf, rows, fields: fieldsHeader ? fieldsHeader.split(',') : [] };
119
+ }
120
+
78
121
  async createMapInSpace(spaceId, { file, mapName, dataTypes, selectedFields, fieldWeights }) {
79
122
  const { apiBaseUrl, apiKey } = this._credentials();
80
123
  const root = normalizeBaseUrl(apiBaseUrl);
@@ -24,7 +24,7 @@ export class McpClientService {
24
24
  const transport = new StreamableHTTPClientTransport(new URL(mcpUrl(cfg)), {
25
25
  requestInit: { headers: this._headers(cfg) },
26
26
  });
27
- const client = new Client({ name: 'mantisai-cli', version: '3.0.0' });
27
+ const client = new Client({ name: 'mantisai-cli', version: '3.1.0' });
28
28
  await client.connect(transport);
29
29
  try {
30
30
  return await fn(client);
@@ -8,6 +8,7 @@
8
8
  * @property {(spaceId: string, name?: string) => Promise<Thread>} createSpaceState
9
9
  * @property {(opts: { name: string, isPublic?: boolean }) => Promise<Space>} createSpace
10
10
  * @property {(spaceId: string, opts: object) => Promise<object>} createMapInSpace
11
+ * @property {(opts: { uri: string, spaceStateId: string, fields?: string[], includeEmbedding?: boolean }) => Promise<{ data: Buffer, rows: number, fields: string[] }>} exportUri
11
12
  */
12
13
 
13
14
  export {};
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const EXPORT_SUBDIR = 'mantis_data';
6
+
7
+ /** Local sandbox dir for exported parquet files: ~/.mantis/mantis_data/ */
8
+ export function exportDir() {
9
+ return path.join(os.homedir(), '.mantis', EXPORT_SUBDIR);
10
+ }
11
+
12
+ function suggestFilename(uri, nRows) {
13
+ const tail = String(uri).replace(/\/+$/, '').split('/').pop() || 'export';
14
+ const safe = tail.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 40);
15
+ return `${safe}_${nRows}rows.parquet`;
16
+ }
17
+
18
+ /**
19
+ * Fetches the parquet behind a Mantis URI from the API and writes it under
20
+ * ~/.mantis/mantis_data/. The CLI-local twin of the in-sandbox `export` MCP
21
+ * tool — same parquet, written to the user's own machine instead of a
22
+ * container.
23
+ */
24
+ export class ExportService {
25
+ constructor({ configStore, client }) {
26
+ this.configStore = configStore;
27
+ this.client = client;
28
+ }
29
+
30
+ async exportUri(uri, { fields, out, includeEmbedding = false } = {}) {
31
+ if (!uri) throw new Error('A Mantis URI is required (e.g. mantis://map/<id>).');
32
+ const cfg = this.configStore.requireAuth();
33
+ if (!cfg.spaceStateId) {
34
+ throw new Error('No thread configured. Run: mantis setup or mantis select thread');
35
+ }
36
+
37
+ const { data, rows, fields: cols } = await this.client.exportUri({
38
+ uri,
39
+ spaceStateId: cfg.spaceStateId,
40
+ fields,
41
+ includeEmbedding,
42
+ });
43
+
44
+ let name = out ? path.basename(out) : suggestFilename(uri, rows);
45
+ if (!name.endsWith('.parquet')) name += '.parquet';
46
+
47
+ const dir = exportDir();
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ const outPath = path.join(dir, name);
50
+ fs.writeFileSync(outPath, data);
51
+
52
+ return { path: outPath, rows, fields: cols, format: 'parquet', size_bytes: data.length };
53
+ }
54
+ }
@@ -1,14 +1,15 @@
1
1
  import { DEFAULT_API_BASE, DEVELOPER_PORTAL_URL } from '../constants.js';
2
2
  import { normalizeBaseUrl, mcpUrl } from '../utils/url.js';
3
3
 
4
- const PROVIDERS = new Set(['claude', 'opencode']);
4
+ const PROVIDERS = new Set(['claude', 'opencode', 'codex']);
5
5
 
6
6
  export class SetupService {
7
- constructor({ configStore, selection, claudeSkills, opencodeSkills, ui }) {
7
+ constructor({ configStore, selection, claudeSkills, opencodeSkills, codexSkills, ui }) {
8
8
  this.configStore = configStore;
9
9
  this.selection = selection;
10
10
  this.claudeSkills = claudeSkills;
11
11
  this.opencodeSkills = opencodeSkills;
12
+ this.codexSkills = codexSkills;
12
13
  this.ui = ui;
13
14
  }
14
15
 
@@ -51,14 +52,14 @@ export class SetupService {
51
52
  this.ui.info(`Thread: ${cfg.spaceStateName || '-'} (${cfg.spaceStateId || '-'})`);
52
53
  this.ui.info(`MCP: ${mcpUrl(cfg)}`);
53
54
  this.ui.info('Explore with: mantis tools && mantis use get_space_context');
54
- this.ui.info('Editor skills: mantis setup claude | mantis setup opencode');
55
+ this.ui.info('Editor skills: mantis setup claude | opencode | codex');
55
56
  console.log('');
56
57
  }
57
58
 
58
- runProvider(provider) {
59
+ runProvider(provider, { project = false } = {}) {
59
60
  const p = String(provider || '').toLowerCase();
60
61
  if (!PROVIDERS.has(p)) {
61
- throw new Error(`Unknown provider "${provider}". Use: claude, opencode`);
62
+ throw new Error(`Unknown provider "${provider}". Use: claude, opencode, codex`);
62
63
  }
63
64
 
64
65
  if (p === 'claude') {
@@ -70,12 +71,28 @@ export class SetupService {
70
71
  return { provider: p, skillsDir, installed };
71
72
  }
72
73
 
73
- const { globalSkillsDir, projectSkillsDir, installed } = this.opencodeSkills.sync();
74
- this.ui.banner('Mantis skills → OpenCode');
74
+ if (p === 'opencode') {
75
+ const { globalSkillsDir, projectSkillsDir, installed } = this.opencodeSkills.sync();
76
+ this.ui.banner('Mantis skills → OpenCode');
77
+ this.ui.success(`Synced ${installed.length} skill(s)`);
78
+ this.ui.info(`Global: ${globalSkillsDir}`);
79
+ this.ui.info(`Project: ${projectSkillsDir}`);
80
+ for (const s of installed) this.ui.info(`${s.slash} ← skills/${s.source}`);
81
+ return { provider: p, globalSkillsDir, projectSkillsDir, installed };
82
+ }
83
+
84
+ const { globalSkillsDir, projectSkillsDir, installed } = this.codexSkills.sync(undefined, { project });
85
+ this.ui.banner('Mantis skills → Codex');
75
86
  this.ui.success(`Synced ${installed.length} skill(s)`);
76
- this.ui.info(`Global: ${globalSkillsDir}`);
77
- this.ui.info(`Project: ${projectSkillsDir}`);
78
- for (const s of installed) this.ui.info(`${s.slash} ← skills/${s.source}`);
87
+ this.ui.info(`USER: ${globalSkillsDir}`);
88
+ if (projectSkillsDir) {
89
+ this.ui.info(`REPO: ${projectSkillsDir}`);
90
+ this.ui.info('Same skill names in USER + REPO may appear twice in Codex; disable one via /skills if needed.');
91
+ } else {
92
+ this.ui.info('Repo copy skipped. Re-run with --project to also write ./.agents/skills/ for team commit.');
93
+ }
94
+ for (const s of installed) this.ui.info(`$${s.name} ← skills/${s.source}`);
95
+ this.ui.info('Invoke with $mantis or /skills. Restart Codex if new skills do not appear.');
79
96
  return { provider: p, globalSkillsDir, projectSkillsDir, installed };
80
97
  }
81
98
  }
@@ -1,25 +1,94 @@
1
1
  import { DISABLED_MCP_TOOLS } from '../constants.js';
2
2
 
3
+ const NOTEBOOK_TOOLS = new Set([
4
+ 'add_cell',
5
+ 'edit_cell',
6
+ 'delete_cell',
7
+ 'execute_cell',
8
+ 'get_cell',
9
+ 'get_cell_count',
10
+ 'check_task_status',
11
+ ]);
12
+
13
+ // Synthetic tool entry for `export`. The server-side MCP `export` needs an
14
+ // agent sandbox the CLI can't provide, so it's filtered from the raw list;
15
+ // this local variant resolves the URI via REST and writes parquet under
16
+ // ~/.mantis/mantis_data/ on the user's machine instead.
17
+ const LOCAL_EXPORT_TOOL = {
18
+ name: 'export',
19
+ description:
20
+ 'Export every row behind a Mantis URI to a local parquet file under '
21
+ + '~/.mantis/mantis_data/. Use for bulk-row analysis (correlations, '
22
+ + 'distributions, top-K, outliers) instead of paging inspect(). Read it '
23
+ + "back with pandas: pd.read_parquet(<path>). Always adds _point_id, "
24
+ + '_cluster_id, _cluster_label. Capped at 200,000 rows.',
25
+ inputSchema: {
26
+ type: 'object',
27
+ properties: {
28
+ uri: {
29
+ type: 'string',
30
+ description: 'Mantis URI resolving to a point set (map, cluster, bag, point, selection).',
31
+ },
32
+ fields: {
33
+ type: 'array',
34
+ items: { type: 'string' },
35
+ description: 'Column names to project. Omit to export all fields.',
36
+ },
37
+ filename: {
38
+ type: 'string',
39
+ description: 'Output filename (basename only). Defaults to <uri-tail>_<n>rows.parquet.',
40
+ },
41
+ include_embedding: {
42
+ type: 'boolean',
43
+ description: 'Add _embedding (1536-d) and _embedding_2d columns.',
44
+ },
45
+ },
46
+ required: ['uri'],
47
+ },
48
+ };
49
+
50
+ function normalizeFields(fields) {
51
+ if (fields == null) return undefined;
52
+ if (Array.isArray(fields)) return fields;
53
+ // `--fields title,rating` arrives as a comma string via parseToolArgs.
54
+ return String(fields).split(',').map((s) => s.trim()).filter(Boolean);
55
+ }
56
+
3
57
  function disabledToolError(name) {
4
58
  if (name === 'create_space') {
5
59
  return 'create_space is disabled in the CLI. Use: mantis setup or mantis select space';
6
60
  }
61
+ if (name === 'cite_file') {
62
+ return `${name} is unavailable in the CLI — it needs an agent sandbox (X-Chat-ID + live container) that the CLI cannot provide. It only works for in-sandbox claude_code/opencode agents.`;
63
+ }
64
+ if (NOTEBOOK_TOOLS.has(name)) {
65
+ return `${name} is unavailable in the CLI — notebook cell tools round-trip through the user's notebook UI and are not intended for agent/CLI use.`;
66
+ }
7
67
  return `${name} is disabled in the CLI. Use: mantis create map or mantis create codebase`;
8
68
  }
9
69
 
10
70
  export class ToolService {
11
- constructor({ mcp }) {
71
+ constructor({ mcp, exporter }) {
12
72
  this.mcp = mcp;
73
+ this.exporter = exporter;
13
74
  }
14
75
 
15
76
  async listTools() {
16
77
  const data = await this.mcp.listTools();
17
- return {
18
- tools: (data.tools || []).filter((t) => !DISABLED_MCP_TOOLS.has(t.name)),
19
- };
78
+ const tools = (data.tools || []).filter((t) => !DISABLED_MCP_TOOLS.has(t.name));
79
+ // inject the local export variant so it's discoverable via `mantis tools`.
80
+ tools.push(LOCAL_EXPORT_TOOL);
81
+ return { tools };
20
82
  }
21
83
 
22
84
  useTool(name, args = {}) {
85
+ if (name === 'export') {
86
+ return this.exporter.exportUri(args.uri, {
87
+ fields: normalizeFields(args.fields),
88
+ out: args.filename ?? args.out,
89
+ includeEmbedding: args.include_embedding ?? args.includeEmbedding ?? false,
90
+ });
91
+ }
23
92
  if (DISABLED_MCP_TOOLS.has(name)) throw new Error(disabledToolError(name));
24
93
  return this.mcp.callTool(name, args);
25
94
  }
@@ -23,6 +23,9 @@ export function prepareSkillContent(content, installName) {
23
23
  yaml = /^name:\s/m.test(yaml)
24
24
  ? yaml.replace(/^name:\s.*$/m, `name: ${installName}`)
25
25
  : `name: ${installName}\n${yaml}`;
26
+ if (!/^description:\s/m.test(yaml)) {
27
+ yaml = `${yaml}\ndescription: Mantis CLI skill (${installName}).`;
28
+ }
26
29
  return `---\n${yaml}\n---${body}`;
27
30
  }
28
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mantisai-cli",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Mantis CLI — spaces, maps, and MCP tools for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,6 +33,7 @@ Install editor skills (CLI-only workflow, no MCP plugin):
33
33
  ```bash
34
34
  mantis setup claude # ~/.claude/skills/
35
35
  mantis setup opencode # ~/.config/opencode/skills/ + .opencode/skills/
36
+ mantis setup codex # ~/.agents/skills/ (add --project for ./.agents/skills/)
36
37
  ```
37
38
 
38
39
  ## MCP tools via CLI
@@ -42,13 +43,38 @@ Every Mantis MCP tool is available through the CLI — no editor MCP plugin requ
42
43
  ```bash
43
44
  mantis tools
44
45
  mantis use get_space_context
45
- mantis use get_cluster_children --map-id <uuid> --cluster-id <id>
46
- mantis use general_search --query "your search"
46
+ mantis use search --query "your search"
47
+ mantis use inspect --uri "<mantis-uri>"
47
48
  ```
48
49
 
49
50
  Use `--args '{"key":"value"}'` for complex arguments. Kebab flags map to snake_case (`--map-id` → `map_id`).
50
51
 
51
- Use map IDs, field types, and cluster names from the `get_space_context` output — do not guess them.
52
+ Use map IDs, field types, and URIs from the `get_space_context` output — do not guess them.
53
+
54
+ Common tools: `get_space_context`, `search`, `inspect`, `compare`, `union`/`intersect`/`diff`, the bag mutators (`create_bag`, `add_to_bag`, `filter_to_bag`, …), `set_plot_variables`, `legend_command`, `create_page`.
55
+
56
+ Not available via `mantis use`:
57
+ - `create_space`, `create_map_from_url`, `modify_map_from_url` — use `mantis setup` / `mantis create map` instead.
58
+ - `cite_file` — needs an agent sandbox the CLI can't provide; in-sandbox agents only.
59
+ - the notebook cell tools (`add_cell`, `execute_cell`, …) — not intended for agent use.
60
+
61
+ ## Bulk export (local parquet)
62
+
63
+ `mantis use export` resolves a URI to its rows and writes a local parquet file under `~/.mantis/mantis_data/`. Use it when a question needs MANY rows (correlations, distributions, top-K, outliers) instead of paging `inspect`:
64
+
65
+ ```bash
66
+ mantis use export --uri "mantis://map/<id>" # all rows
67
+ mantis use export --uri "mantis://map/<id>/cluster/<cid>" --fields title,rating
68
+ mantis use export --uri "mantis://map/<id>" --include-embedding # + _embedding (1536-d), _embedding_2d
69
+ ```
70
+
71
+ Prints `{path, rows, fields, size_bytes}`. Then read it locally:
72
+
73
+ ```bash
74
+ python3 -c "import pandas as pd; df = pd.read_parquet('<path>'); print(df.head())"
75
+ ```
76
+
77
+ Always-present columns: `_point_id`, `_cluster_id`, `_cluster_label` (so you can `df.groupby('_cluster_label')`). Capped at 200,000 rows — narrow the URI if you hit `too_many_rows`.
52
78
 
53
79
  ## REST via CLI (setup & resources)
54
80