m3triq 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.
@@ -0,0 +1,96 @@
1
+ import { createClient } from '../cli.js';
2
+ import { requireProject } from '../config.js';
3
+ import { output, formatTable } from '../output.js';
4
+ export function registerDataCommands(program) {
5
+ program
6
+ .command('data')
7
+ .description('List project data (datasets, structures, files)')
8
+ .option('--type <type>', 'Filter by type: dataset, structure, file, folder')
9
+ .option('--limit <n>', 'Max items', '20')
10
+ .action(async (opts) => {
11
+ const project = requireProject();
12
+ const client = createClient();
13
+ const items = await client.listProjectData(project.id);
14
+ let filtered = items;
15
+ if (opts.type) {
16
+ filtered = items.filter((d) => String(d.data_type).includes(opts.type));
17
+ }
18
+ filtered = filtered.slice(0, parseInt(opts.limit));
19
+ const data = filtered.map((d) => ({
20
+ id: d.id,
21
+ type: d.data_type,
22
+ name: d.name,
23
+ created_at: d.created_at,
24
+ }));
25
+ const rows = filtered.map((d) => [
26
+ String(d.id).substring(0, 8),
27
+ String(d.data_type),
28
+ String(d.name).substring(0, 50),
29
+ ]);
30
+ output(data, formatTable(['ID', 'Type', 'Name'], rows));
31
+ });
32
+ program
33
+ .command('dataset')
34
+ .argument('<id>', 'Dataset ID (full or short 8-char)')
35
+ .option('--limit <n>', 'Max rows to show', '20')
36
+ .option('--columns <cols>', 'Comma-separated columns to show')
37
+ .description('View dataset rows')
38
+ .action(async (id, opts) => {
39
+ const project = requireProject();
40
+ const client = createClient();
41
+ // Resolve short ID
42
+ let fullId = id;
43
+ if (!id.includes('-')) {
44
+ const items = await client.listProjectData(project.id);
45
+ const match = items.find((d) => String(d.id).startsWith(id));
46
+ if (!match) {
47
+ process.stderr.write(`Error: No data found matching "${id}"\n`);
48
+ process.exit(1);
49
+ }
50
+ fullId = String(match.id);
51
+ }
52
+ const item = await client.getProjectData(project.id, fullId);
53
+ const jsonData = item.json_data;
54
+ if (!jsonData) {
55
+ // Not a dataset — show metadata
56
+ output(item, `${item.name}\nType: ${item.data_type}\nNo tabular data.`);
57
+ return;
58
+ }
59
+ // Handle both formats: list of objects or {columns, rows}
60
+ let allRows;
61
+ if (Array.isArray(jsonData)) {
62
+ allRows = jsonData;
63
+ }
64
+ else if ('rows' in jsonData && Array.isArray(jsonData.rows)) {
65
+ allRows = jsonData.rows;
66
+ }
67
+ else {
68
+ output(jsonData, JSON.stringify(jsonData, null, 2).substring(0, 2000));
69
+ return;
70
+ }
71
+ const limit = parseInt(opts.limit);
72
+ const displayRows = allRows.slice(0, limit);
73
+ if (displayRows.length === 0) {
74
+ output({ name: item.name, rows: 0 }, `${item.name}: empty dataset`);
75
+ return;
76
+ }
77
+ // Determine columns
78
+ let columns;
79
+ if (opts.columns) {
80
+ columns = opts.columns.split(',').map((c) => c.trim());
81
+ }
82
+ else {
83
+ columns = Object.keys(displayRows[0]);
84
+ }
85
+ const tableRows = displayRows.map((r) => columns.map(c => {
86
+ const val = r[c];
87
+ if (val === null || val === undefined)
88
+ return '-';
89
+ const s = String(val);
90
+ return s.length > 40 ? s.substring(0, 37) + '...' : s;
91
+ }));
92
+ const fullData = { name: item.name, total_rows: allRows.length, columns, rows: displayRows };
93
+ const header = `${item.name} (${allRows.length} rows)`;
94
+ output(fullData, `${header}\n\n${formatTable(columns, tableRows)}`);
95
+ });
96
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerDesignCommands(program: Command): void;
@@ -0,0 +1,219 @@
1
+ import fs from 'node:fs';
2
+ import https from 'node:https';
3
+ import { createClient, getConsoleUrl } from '../cli.js';
4
+ import { requireProject, getEffectiveConfig } from '../config.js';
5
+ import { output } from '../output.js';
6
+ import { jobUrl, maybeOpenBrowser } from '../url.js';
7
+ const RFANTIBODY_URL = 'https://rfantibody-app.calmwater-f667a14d.southeastasia.azurecontainerapps.io';
8
+ /** Long-running HTTPS POST that bypasses Node's default 300s headers timeout */
9
+ function longPost(url, body) {
10
+ return new Promise((resolve, reject) => {
11
+ const parsed = new URL(url);
12
+ const req = https.request({
13
+ hostname: parsed.hostname,
14
+ port: parsed.port || 443,
15
+ path: parsed.pathname,
16
+ method: 'POST',
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ 'Content-Length': Buffer.byteLength(body),
20
+ },
21
+ timeout: 1800_000,
22
+ }, (res) => {
23
+ const chunks = [];
24
+ res.on('data', (chunk) => chunks.push(chunk));
25
+ res.on('end', () => {
26
+ const text = Buffer.concat(chunks).toString();
27
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, data: text });
28
+ });
29
+ });
30
+ req.on('error', reject);
31
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout (30 min)')); });
32
+ req.write(body);
33
+ req.end();
34
+ });
35
+ }
36
+ async function fetchPdb(pdbId) {
37
+ const url = `https://files.rcsb.org/download/${pdbId.toUpperCase()}.pdb`;
38
+ const res = await fetch(url);
39
+ if (!res.ok)
40
+ throw new Error(`Failed to fetch PDB ${pdbId}: ${res.status}`);
41
+ return res.text();
42
+ }
43
+ async function waitForWarmup(baseUrl) {
44
+ const maxWait = 300_000; // 5 min
45
+ const start = Date.now();
46
+ process.stderr.write('Warming up RFAntibody (Azure scale-to-zero)...');
47
+ while (Date.now() - start < maxWait) {
48
+ try {
49
+ const res = await fetch(`${baseUrl}/v1/health/ready`, { signal: AbortSignal.timeout(10_000) });
50
+ if (res.ok) {
51
+ process.stderr.write(' ready.\n');
52
+ return;
53
+ }
54
+ }
55
+ catch {
56
+ // still starting
57
+ }
58
+ process.stderr.write('.');
59
+ await new Promise(r => setTimeout(r, 5_000));
60
+ }
61
+ throw new Error('RFAntibody service did not become ready within 5 minutes');
62
+ }
63
+ export function registerDesignCommands(program) {
64
+ const design = program.command('design').description('AI-powered protein design');
65
+ // m3t design rfantibody
66
+ design.command('rfantibody')
67
+ .argument('<target>', 'Target PDB ID (e.g., 6VXX) or path to .pdb file')
68
+ .requiredOption('--hotspots <residues>', 'Binding hotspot residues (e.g., A100,A105,A110)')
69
+ .option('--type <type>', 'Antibody type: nanobody or scfv', 'nanobody')
70
+ .option('--designs <n>', 'Number of designs (1-10)', parseInt, 3)
71
+ .option('--seqs <n>', 'Sequences per backbone (1-8)', parseInt, 1)
72
+ .option('--cdr <loops>', 'CDR loop specs (e.g., H1:7,H2:6,H3:5-13)')
73
+ .description('Design de novo antibodies with RFAntibody (Azure T4 GPU, ~3-5 min)')
74
+ .action(async (target, opts) => {
75
+ const project = requireProject();
76
+ const client = createClient();
77
+ const config = getEffectiveConfig();
78
+ const consoleUrl = getConsoleUrl();
79
+ // Resolve target PDB
80
+ process.stderr.write(`Resolving target: ${target}\n`);
81
+ let rawPdb;
82
+ if (fs.existsSync(target)) {
83
+ rawPdb = fs.readFileSync(target, 'utf-8');
84
+ }
85
+ else {
86
+ rawPdb = await fetchPdb(target);
87
+ }
88
+ const pdbContent = rawPdb;
89
+ const hotspots = opts.hotspots;
90
+ const framework = opts.type === 'scfv' ? 'human_scfv' : 'nanobody_vhh';
91
+ const title = `RFAntibody ${opts.type} → ${target} (${hotspots})`;
92
+ // Create Django job for tracking
93
+ const jobResult = await client.createRFAntibodyJob({
94
+ project_id: project.id,
95
+ title,
96
+ job_type: 'rfantibody_design',
97
+ antibody_type: opts.type,
98
+ });
99
+ const jobId = jobResult.job_id;
100
+ process.stderr.write(`Job created: ${jobId.substring(0, 8)}\n`);
101
+ const url = jobUrl(consoleUrl, project.id, jobId);
102
+ maybeOpenBrowser(url);
103
+ // Warm up Azure service
104
+ await waitForWarmup(RFANTIBODY_URL);
105
+ // Call RFAntibody API
106
+ process.stderr.write(`Running RFAntibody pipeline (${opts.designs} designs)...\n`);
107
+ const startTime = Date.now();
108
+ try {
109
+ // Submit async job
110
+ const submitRes = await longPost(`${RFANTIBODY_URL}/v1/design-antibody/async`, JSON.stringify({
111
+ target_pdb: pdbContent,
112
+ hotspots,
113
+ antibody_type: opts.type,
114
+ framework,
115
+ num_designs: opts.designs,
116
+ seqs_per_struct: opts.seqs,
117
+ cdr_loops: opts.cdr,
118
+ }));
119
+ if (!submitRes.ok) {
120
+ throw new Error(`RFAntibody API error ${submitRes.status}: ${submitRes.data.substring(0, 300)}`);
121
+ }
122
+ const { task_id } = JSON.parse(submitRes.data);
123
+ process.stderr.write(`Task submitted: ${task_id.substring(0, 8)}\n`);
124
+ let result = null;
125
+ const maxPollTime = 1200_000; // 20 min
126
+ const pollStart = Date.now();
127
+ let pollErrors = 0;
128
+ while (Date.now() - pollStart < maxPollTime) {
129
+ await new Promise(r => setTimeout(r, 15_000)); // 15s between polls
130
+ process.stderr.write('.');
131
+ try {
132
+ const pollRes = await fetch(`${RFANTIBODY_URL}/v1/task/${task_id}`, { signal: AbortSignal.timeout(10_000) });
133
+ if (pollRes.status === 404) {
134
+ // Task file not found — container may have recycled
135
+ pollErrors++;
136
+ if (pollErrors >= 5)
137
+ throw new Error('Task lost — Azure container may have recycled. Try again.');
138
+ continue; // retry: file might not be written yet
139
+ }
140
+ if (!pollRes.ok) {
141
+ // 500s are expected when GPU is busy — keep retrying
142
+ pollErrors++;
143
+ if (pollErrors >= 10)
144
+ throw new Error(`Poll failed ${pollErrors} times (last: HTTP ${pollRes.status})`);
145
+ continue;
146
+ }
147
+ pollErrors = 0;
148
+ const task = await pollRes.json();
149
+ if (task.status === 'completed') {
150
+ result = task.result;
151
+ process.stderr.write(' done.\n');
152
+ break;
153
+ }
154
+ else if (task.status === 'failed') {
155
+ throw new Error(task.error || 'Pipeline failed');
156
+ }
157
+ }
158
+ catch (e) {
159
+ if (e.message.includes('Task lost') || e.message.includes('Poll failed') || e.message.includes('Pipeline failed'))
160
+ throw e;
161
+ // Network errors during GPU compute are expected
162
+ pollErrors++;
163
+ if (pollErrors >= 10)
164
+ throw e;
165
+ }
166
+ }
167
+ if (!result)
168
+ throw new Error('Pipeline timed out after 20 minutes');
169
+ if (!result)
170
+ throw new Error('No result returned');
171
+ const r = result;
172
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
173
+ if (!r.success || !r.designs?.length) {
174
+ // Update job as failed
175
+ await client.callbackJob(jobId, {
176
+ status: 'failed',
177
+ percentage: 0,
178
+ message: (r.error || 'No designs generated').substring(0, 190),
179
+ result: { error: r.error || 'No designs generated' },
180
+ });
181
+ process.stderr.write(`Failed: ${r.error || 'No designs generated'}\n`);
182
+ process.exit(1);
183
+ }
184
+ // Update job as completed
185
+ await client.callbackJob(jobId, {
186
+ status: 'completed',
187
+ percentage: 100,
188
+ message: `${r.designs.length} designs generated`,
189
+ result: { ...r, num_designs: r.designs.length },
190
+ });
191
+ // Format output
192
+ const designs = r.designs.map((d, i) => ({
193
+ rank: i + 1,
194
+ sequence: d.sequence,
195
+ rf2_plddt: d.rf2_plddt,
196
+ rf2_i_pae: d.rf2_i_pae,
197
+ rf2_pae: d.rf2_pae,
198
+ }));
199
+ const humanLines = [
200
+ `RFAntibody completed in ${elapsed}s — ${r.designs.length} designs`,
201
+ `Job ID: ${jobId.substring(0, 8)}`,
202
+ '',
203
+ ...designs.map(d => `#${d.rank} pLDDT: ${d.rf2_plddt?.toFixed(1) ?? '-'} iPAE: ${d.rf2_i_pae?.toFixed(1) ?? '-'} ${d.sequence.substring(0, 60)}...`),
204
+ '',
205
+ `View: ${url}`,
206
+ ];
207
+ output({ job_id: jobId, elapsed_seconds: elapsed, designs, url }, humanLines.join('\n'));
208
+ }
209
+ catch (err) {
210
+ await client.callbackJob(jobId, {
211
+ status: 'failed',
212
+ percentage: 0,
213
+ message: String(err).substring(0, 190),
214
+ result: { error: String(err) },
215
+ }).catch(() => { });
216
+ throw err;
217
+ }
218
+ });
219
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerDockingCommands(program: Command): void;
@@ -0,0 +1,157 @@
1
+ import fs from 'node:fs';
2
+ import { createClient, getConsoleUrl } from '../cli.js';
3
+ import { requireProject } from '../config.js';
4
+ import { output } from '../output.js';
5
+ import { jobUrl, maybeOpenBrowser } from '../url.js';
6
+ function parseCompoundsFile(filePath) {
7
+ const content = filePath === '-'
8
+ ? fs.readFileSync(0, 'utf-8')
9
+ : fs.readFileSync(filePath, 'utf-8');
10
+ const trimmed = content.trim();
11
+ if (trimmed.startsWith('[')) {
12
+ return JSON.parse(trimmed);
13
+ }
14
+ const lines = trimmed.split('\n');
15
+ const header = lines[0].toLowerCase();
16
+ const sep = header.includes('\t') ? '\t' : ',';
17
+ const cols = header.split(sep).map(c => c.trim());
18
+ const smilesIdx = cols.indexOf('smiles');
19
+ const nameIdx = cols.indexOf('name');
20
+ if (smilesIdx === -1)
21
+ throw new Error('CSV must have a "smiles" column');
22
+ return lines.slice(1).filter(l => l.trim()).map(l => {
23
+ const parts = l.split(sep);
24
+ return {
25
+ smiles: parts[smilesIdx].trim(),
26
+ name: nameIdx >= 0 ? parts[nameIdx]?.trim() : undefined,
27
+ };
28
+ });
29
+ }
30
+ /** Resolve PDB argument: if it's a file path, read content; otherwise return as-is (PDB ID). */
31
+ function resolvePdb(pdb) {
32
+ if (fs.existsSync(pdb)) {
33
+ return { content: fs.readFileSync(pdb, 'utf-8'), label: pdb.split('/').pop() || pdb };
34
+ }
35
+ return { content: pdb, label: pdb };
36
+ }
37
+ /** Shared binding site options for site-directed docking (Vina/GNINA) */
38
+ function addSiteOptions(cmd) {
39
+ return cmd
40
+ .requiredOption('--cx <x>', 'Binding site center X', parseFloat)
41
+ .requiredOption('--cy <y>', 'Binding site center Y', parseFloat)
42
+ .requiredOption('--cz <z>', 'Binding site center Z', parseFloat)
43
+ .option('--sx <x>', 'Search box size X (default: 20)', parseFloat)
44
+ .option('--sy <y>', 'Search box size Y (default: 20)', parseFloat)
45
+ .option('--sz <z>', 'Search box size Z (default: 20)', parseFloat);
46
+ }
47
+ export function registerDockingCommands(program) {
48
+ // ── m3t dock <method> ─────────────────────────────────────────
49
+ const dock = program.command('dock').description('Dock a compound against a protein target');
50
+ // m3t dock gnina
51
+ addSiteOptions(dock.command('gnina')
52
+ .argument('<smiles>', 'SMILES string of the ligand')
53
+ .argument('<pdb>', 'PDB ID (e.g., 5NJ8) or path to .pdb file')
54
+ .option('--exhaustiveness <n>', 'Search exhaustiveness', parseInt)
55
+ .description('Dock with GNINA CNN scoring (Azure T4 GPU, ~6s/compound)')).action(async (smiles, pdb, opts) => {
56
+ await runSiteDock(smiles, pdb, 'gnina', opts);
57
+ });
58
+ // m3t dock vina
59
+ addSiteOptions(dock.command('vina')
60
+ .argument('<smiles>', 'SMILES string of the ligand')
61
+ .argument('<pdb>', 'PDB ID (e.g., 5NJ8) or path to .pdb file')
62
+ .option('--exhaustiveness <n>', 'Search exhaustiveness', parseInt)
63
+ .description('Dock with AutoDock Vina (Cloud Run CPU, ~30s/compound)')).action(async (smiles, pdb, opts) => {
64
+ await runSiteDock(smiles, pdb, 'vina', opts);
65
+ });
66
+ // m3t dock diffdock
67
+ dock.command('diffdock')
68
+ .argument('<smiles>', 'SMILES string of the ligand')
69
+ .argument('<pdb>', 'PDB ID (e.g., 5NJ8) or path to .pdb file')
70
+ .option('--samples <n>', 'Number of pose samples (default: 10)', parseInt)
71
+ .description('Blind docking with DiffDock (no binding site needed, AI-predicted poses)')
72
+ .action(async (smiles, pdb, opts) => {
73
+ const project = requireProject();
74
+ const client = createClient();
75
+ const consoleUrl = getConsoleUrl();
76
+ const { content: pdbContent } = resolvePdb(pdb);
77
+ const result = await client.createDiffDockJob({
78
+ project_id: project.id,
79
+ ligand_smiles: smiles,
80
+ protein_pdb: pdbContent,
81
+ num_samples: opts.samples,
82
+ });
83
+ const url = jobUrl(consoleUrl, project.id, result.job_id);
84
+ maybeOpenBrowser(url);
85
+ const data = { job_id: result.job_id, method: 'diffdock', url };
86
+ output(data, `DiffDock job created\nJob ID: ${result.job_id.substring(0, 8)}\nEstimated: ~2 minutes`);
87
+ });
88
+ // ── m3t batch <method> ────────────────────────────────────────
89
+ const batch = program.command('batch').description('Batch dock multiple compounds');
90
+ // m3t batch gnina
91
+ addSiteOptions(batch.command('gnina')
92
+ .argument('<file>', 'Compounds file (CSV/JSON, - for stdin)')
93
+ .argument('<pdb>', 'PDB ID (e.g., 5NJ8) or path to .pdb file')
94
+ .description('Batch dock with GNINA (~6s/compound)')).action(async (file, pdb, opts) => {
95
+ await runBatchDock(file, pdb, 'gnina', opts);
96
+ });
97
+ // m3t batch vina
98
+ addSiteOptions(batch.command('vina')
99
+ .argument('<file>', 'Compounds file (CSV/JSON, - for stdin)')
100
+ .argument('<pdb>', 'PDB ID (e.g., 5NJ8) or path to .pdb file')
101
+ .description('Batch dock with Vina (~30s/compound)')).action(async (file, pdb, opts) => {
102
+ await runBatchDock(file, pdb, 'vina', opts);
103
+ });
104
+ }
105
+ async function runSiteDock(smiles, pdb, method, opts) {
106
+ const project = requireProject();
107
+ const client = createClient();
108
+ const consoleUrl = getConsoleUrl();
109
+ const { content: pdbContent, label: pdbLabel } = resolvePdb(pdb);
110
+ const title = `Dock ${smiles.substring(0, 30)} → ${pdbLabel}`;
111
+ const result = await client.createDockingJob({
112
+ project_id: project.id,
113
+ title,
114
+ protein_pdb: pdbContent,
115
+ ligand_smiles: smiles,
116
+ scoring_function: method,
117
+ exhaustiveness: opts.exhaustiveness,
118
+ center_x: opts.cx,
119
+ center_y: opts.cy,
120
+ center_z: opts.cz,
121
+ size_x: opts.sx,
122
+ size_y: opts.sy,
123
+ size_z: opts.sz,
124
+ });
125
+ const url = jobUrl(consoleUrl, project.id, result.job_id);
126
+ maybeOpenBrowser(url);
127
+ const timeEst = method === 'gnina' ? '~6s' : '~30s';
128
+ const data = { job_id: result.job_id, method, url };
129
+ output(data, `${method.toUpperCase()} docking job created\nJob ID: ${result.job_id.substring(0, 8)}\nEstimated: ${timeEst}`);
130
+ }
131
+ async function runBatchDock(file, pdb, method, opts) {
132
+ const project = requireProject();
133
+ const client = createClient();
134
+ const consoleUrl = getConsoleUrl();
135
+ const compounds = parseCompoundsFile(file);
136
+ const { content: pdbContent, label: pdbLabel } = resolvePdb(pdb);
137
+ const title = `Batch ${method.toUpperCase()} ${compounds.length} compounds → ${pdbLabel}`;
138
+ const result = await client.createBatchDockingJob({
139
+ project_id: project.id,
140
+ title,
141
+ protein_pdb: pdbContent,
142
+ ligand_smiles_list: compounds.map(c => c.smiles),
143
+ scoring_function: method,
144
+ center_x: opts.cx,
145
+ center_y: opts.cy,
146
+ center_z: opts.cz,
147
+ size_x: opts.sx,
148
+ size_y: opts.sy,
149
+ size_z: opts.sz,
150
+ });
151
+ const url = jobUrl(consoleUrl, project.id, result.job_id);
152
+ maybeOpenBrowser(url);
153
+ const perCompound = method === 'gnina' ? 6 : 30;
154
+ const totalMin = Math.ceil((compounds.length * perCompound) / 60);
155
+ const data = { job_id: result.job_id, compounds: compounds.length, method, url };
156
+ output(data, `${method.toUpperCase()} batch docking created\nJob ID: ${result.job_id.substring(0, 8)}\nCompounds: ${compounds.length}\nEstimated: ~${totalMin} minutes`);
157
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerJobCommands(program: Command): void;
@@ -0,0 +1,89 @@
1
+ import { createClient, getConsoleUrl } from '../cli.js';
2
+ import { requireProject } from '../config.js';
3
+ import { output, formatTable } from '../output.js';
4
+ import { jobUrl } from '../url.js';
5
+ async function resolveJobId(client, projectId, input) {
6
+ // Full UUID — use directly
7
+ if (input.includes('-'))
8
+ return input;
9
+ // Short ID — search recent jobs for a prefix match
10
+ const jobs = await client.listJobs(projectId, 200);
11
+ const match = jobs.find(j => j.id.startsWith(input));
12
+ if (!match) {
13
+ process.stderr.write(`Error: No job found matching "${input}" in the 200 most recent jobs.\nTry using the full UUID instead.\n`);
14
+ process.exit(1);
15
+ }
16
+ return match.id;
17
+ }
18
+ function summarizeResults(jobType, results) {
19
+ if (jobType.includes('docking')) {
20
+ const score = results.best_score ?? results.affinity ?? results.score;
21
+ if (score !== undefined)
22
+ return `Best score: ${score} kcal/mol`;
23
+ }
24
+ if (jobType.includes('prediction') || jobType.includes('esmfold') || jobType.includes('alphafold')) {
25
+ const plddt = results.plddt ?? results.mean_plddt;
26
+ if (plddt !== undefined)
27
+ return `Mean pLDDT: ${plddt}`;
28
+ }
29
+ return '';
30
+ }
31
+ export function registerJobCommands(program) {
32
+ program
33
+ .command('jobs')
34
+ .description('List recent jobs in the active project')
35
+ .option('--status <status>', 'Filter by status (completed, failed, running, queued)')
36
+ .option('--limit <n>', 'Max jobs to return', '20')
37
+ .action(async (opts) => {
38
+ const project = requireProject();
39
+ const client = createClient();
40
+ let jobs = await client.listJobs(project.id, parseInt(opts.limit));
41
+ if (opts.status) {
42
+ jobs = jobs.filter(j => j.status === opts.status);
43
+ }
44
+ const data = jobs.map(j => ({
45
+ id: j.id,
46
+ status: j.status,
47
+ type: j.job_type,
48
+ title: j.title,
49
+ progress: j.progress_percentage,
50
+ created_at: j.created_at,
51
+ }));
52
+ const rows = jobs.map(j => [
53
+ j.id.substring(0, 8),
54
+ j.status,
55
+ j.job_type,
56
+ j.title || '',
57
+ ]);
58
+ output(data, formatTable(['ID', 'Status', 'Type', 'Title'], rows));
59
+ });
60
+ program
61
+ .command('job')
62
+ .argument('<id>', 'Job ID (full or short 8-char)')
63
+ .description('Get job status and results')
64
+ .action(async (id) => {
65
+ const project = requireProject();
66
+ const client = createClient();
67
+ const fullId = await resolveJobId(client, project.id, id);
68
+ const job = await client.getJob(fullId);
69
+ const consoleUrl = getConsoleUrl();
70
+ const url = jobUrl(consoleUrl, project.id, job.id);
71
+ const lines = [
72
+ `Job: ${job.title || job.id}`,
73
+ `Type: ${job.job_type}`,
74
+ `Status: ${job.status}`,
75
+ `Progress: ${job.progress_percentage}%`,
76
+ ];
77
+ if (job.current_step)
78
+ lines.push(`Step: ${job.current_step}`);
79
+ if (job.completed_at)
80
+ lines.push(`Done: ${job.completed_at}`);
81
+ if (job.result_data && job.status === 'completed') {
82
+ const summary = summarizeResults(job.job_type, job.result_data);
83
+ if (summary)
84
+ lines.push(summary);
85
+ }
86
+ lines.push(`View: ${url}`);
87
+ output(job, lines.join('\n'));
88
+ });
89
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerPredictCommands(program: Command): void;
@@ -0,0 +1,39 @@
1
+ import { createClient, getConsoleUrl } from '../cli.js';
2
+ import { requireProject } from '../config.js';
3
+ import { output } from '../output.js';
4
+ import { jobUrl, maybeOpenBrowser } from '../url.js';
5
+ export function registerPredictCommands(program) {
6
+ const predict = program.command('predict').description('Predict 3D protein structure from sequence');
7
+ // m3t predict esmfold
8
+ predict.command('esmfold')
9
+ .argument('<sequence>', 'Amino acid sequence (single letter codes, max 1024aa)')
10
+ .option('--name <name>', 'Name for the prediction')
11
+ .description('Fast structure prediction with ESMFold (~10 seconds)')
12
+ .action(async (sequence, opts) => {
13
+ await runPredict(sequence, 'esmfold', opts.name);
14
+ });
15
+ // m3t predict alphafold2
16
+ predict.command('alphafold2')
17
+ .argument('<sequence>', 'Amino acid sequence (single letter codes, max 2048aa)')
18
+ .option('--name <name>', 'Name for the prediction')
19
+ .description('High-accuracy prediction with AlphaFold2 (~5 minutes, scale-to-zero VM)')
20
+ .action(async (sequence, opts) => {
21
+ await runPredict(sequence, 'alphafold2', opts.name);
22
+ });
23
+ }
24
+ async function runPredict(sequence, method, name) {
25
+ const project = requireProject();
26
+ const client = createClient();
27
+ const consoleUrl = getConsoleUrl();
28
+ const result = await client.createStructurePrediction({
29
+ project_id: project.id,
30
+ sequence,
31
+ method,
32
+ name,
33
+ });
34
+ const url = jobUrl(consoleUrl, project.id, result.job_id);
35
+ maybeOpenBrowser(url);
36
+ const timeEst = method === 'alphafold2' ? '~5 minutes' : '~10 seconds';
37
+ const data = { job_id: result.job_id, method, sequence_length: sequence.length, url };
38
+ output(data, `${method} prediction created\nJob ID: ${result.job_id.substring(0, 8)}\nLength: ${sequence.length} residues\nEstimated: ${timeEst}`);
39
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerProjectCommands(program: Command): void;
@@ -0,0 +1,53 @@
1
+ import { createClient, getConsoleUrl } from '../cli.js';
2
+ import { loadConfig, saveConfig, saveLocalProject, getEffectiveConfig } from '../config.js';
3
+ import { output, formatTable } from '../output.js';
4
+ export function registerProjectCommands(program) {
5
+ program
6
+ .command('projects')
7
+ .description('List all accessible projects')
8
+ .action(async () => {
9
+ const client = createClient();
10
+ const projects = await client.listProjects();
11
+ const activeId = getEffectiveConfig().active_project;
12
+ const data = projects.map(p => ({
13
+ ...p,
14
+ active: p.id === activeId,
15
+ }));
16
+ const rows = projects.map(p => [
17
+ p.id.substring(0, 8),
18
+ p.name,
19
+ p.id === activeId ? '(active)' : '',
20
+ ]);
21
+ output(data, formatTable(['ID', 'Name', ''], rows));
22
+ });
23
+ program
24
+ .command('use')
25
+ .argument('<id>', 'Project ID (full or short 8-char)')
26
+ .option('--global', 'Set globally in ~/.m3triq/config.json instead of local .m3triq')
27
+ .description('Set the active project (writes .m3triq in current directory)')
28
+ .action(async (id, opts) => {
29
+ const client = createClient();
30
+ const projects = await client.listProjects();
31
+ const match = projects.find(p => p.id === id || p.id.startsWith(id));
32
+ if (!match) {
33
+ process.stderr.write(`Error: No project found matching "${id}"\n`);
34
+ process.exit(1);
35
+ }
36
+ if (opts.global) {
37
+ // Save to global config
38
+ const config = loadConfig();
39
+ config.active_project = match.id;
40
+ config.active_project_name = match.name;
41
+ saveConfig(config);
42
+ process.stderr.write(`Saved to ~/.m3triq/config.json (global)\n`);
43
+ }
44
+ else {
45
+ // Save to local .m3triq in cwd
46
+ saveLocalProject(match.id, match.name);
47
+ process.stderr.write(`Saved to .m3triq (${process.cwd()})\n`);
48
+ }
49
+ const consoleUrl = getConsoleUrl();
50
+ const data = { id: match.id, name: match.name, url: `${consoleUrl}/?project=${match.id.substring(0, 8)}` };
51
+ output(data, `Active project: ${match.name} (${match.id.substring(0, 8)})`);
52
+ });
53
+ }