m3triq 0.2.1 → 0.2.3

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/dist/cli.js CHANGED
@@ -23,7 +23,7 @@ const program = new Command();
23
23
  program
24
24
  .name('m3t')
25
25
  .description('M3TRIQ — protein-ligand analysis from the terminal')
26
- .version('0.2.1')
26
+ .version('0.2.2')
27
27
  .option('--json', 'Output as JSON (machine-readable)')
28
28
  .hook('preAction', (thisCommand) => {
29
29
  const opts = thisCommand.optsWithGlobals();
package/dist/client.js CHANGED
@@ -20,6 +20,18 @@ export class M3triqClient {
20
20
  });
21
21
  if (!res.ok) {
22
22
  const text = await res.text();
23
+ if (res.status === 402) {
24
+ let code = '';
25
+ try {
26
+ const j = JSON.parse(text);
27
+ code = j.code || j.error || '';
28
+ }
29
+ catch { /* not JSON */ }
30
+ if (code === 'subscription_required') {
31
+ throw new Error('Your free trial has ended. Subscribe to Pro to continue:\n console.m3triq.com/profile');
32
+ }
33
+ throw new Error('Out of credits. Top up or subscribe at console.m3triq.com/profile');
34
+ }
23
35
  throw new Error(`API error ${res.status}: ${text.substring(0, 200)}`);
24
36
  }
25
37
  return res.json();
@@ -35,14 +35,21 @@ export function registerCreditsCommands(program) {
35
35
  return;
36
36
  }
37
37
  const remainingClass = q.is_quota_exceeded ? '!! EXHAUSTED' : '';
38
+ const isTrial = q.tier === 'free' || q.tier === 'beta';
39
+ const trialEnds = q.trial_ends_at ? new Date(q.trial_ends_at).toLocaleDateString() : null;
38
40
  const periodEnd = q.period_end ? new Date(q.period_end).toLocaleDateString() : '—';
39
- const lines = [
40
- `Tier: ${q.tier}`,
41
- `Used: ${formatCredits(q.credits_used)} / ${formatCredits(q.monthly_credits)} ${progressBar(q.credits_used, q.monthly_credits)} ${formatPercentage(q.usage_percentage || 0)}`,
42
- `Top-up: ${formatCredits(q.topup_credits)}`,
43
- `Remaining: ${formatCredits(q.credits_remaining)} ${remainingClass}`.trimEnd(),
44
- `Resets: ${periodEnd}`,
45
- ];
41
+ const lines = [`Tier: ${q.tier}`];
42
+ if (isTrial) {
43
+ lines.push(q.in_trial
44
+ ? `Trial: ${q.trial_days_remaining ?? 0} day(s) left${trialEnds ? ` — ends ${trialEnds}` : ''}`
45
+ : `Trial: ENDED — subscribe to continue`);
46
+ }
47
+ lines.push(`Used: ${formatCredits(q.credits_used)} / ${formatCredits(q.monthly_credits)} ${progressBar(q.credits_used, q.monthly_credits)} ${formatPercentage(q.usage_percentage || 0)}`, `Top-up: ${formatCredits(q.topup_credits)}${q.topup_locked ? ' (locked — subscribe to use)' : ''}`, `Remaining: ${formatCredits(q.credits_remaining)} ${remainingClass}`.trimEnd());
48
+ if (!isTrial)
49
+ lines.push(`Resets: ${periodEnd}`);
50
+ if (q.has_access === false) {
51
+ lines.push('', 'Subscribe to Pro to keep using M3TRIQ: console.m3triq.com/profile');
52
+ }
46
53
  output(q, lines.join('\n'));
47
54
  });
48
55
  credits
@@ -27,19 +27,112 @@ function parseCompoundsFile(filePath) {
27
27
  };
28
28
  });
29
29
  }
30
- /** Resolve PDB argument: if it's a file path, read content; otherwise return as-is (PDB ID). */
31
- function resolvePdb(pdb) {
30
+ /**
31
+ * Resolve PDB argument to file content.
32
+ * - existing file path → read it
33
+ * - 4-letter PDB ID (e.g. "3HTB") → fetch from RCSB
34
+ * - anything else (assume raw PDB text already) → return as-is
35
+ */
36
+ async function resolvePdb(pdb) {
32
37
  if (fs.existsSync(pdb)) {
33
38
  return { content: fs.readFileSync(pdb, 'utf-8'), label: pdb.split('/').pop() || pdb };
34
39
  }
40
+ if (/^[0-9A-Za-z]{4}$/.test(pdb)) {
41
+ const id = pdb.toUpperCase();
42
+ const res = await fetch(`https://files.rcsb.org/download/${id}.pdb`);
43
+ if (!res.ok) {
44
+ throw new Error(`PDB '${id}' not found at RCSB (HTTP ${res.status}). ` +
45
+ `Check the ID at https://rcsb.org/structure/${id} or pass a local .pdb file.`);
46
+ }
47
+ return { content: await res.text(), label: id };
48
+ }
35
49
  return { content: pdb, label: pdb };
36
50
  }
37
- /** Shared binding site options for site-directed docking (Vina/GNINA) */
51
+ // HETATM residues that are not real ligands waters, ions, common cryoprotectants
52
+ // and buffer components. Excluded from auto binding-site detection.
53
+ const FILLER_HETATMS = new Set([
54
+ 'HOH', 'WAT', 'DOD', 'D2O',
55
+ 'NA', 'CL', 'K', 'BR', 'I', 'F', 'MG', 'CA', 'ZN', 'FE', 'MN', 'CU', 'NI', 'CO', 'CD', 'HG', 'PB', 'BA', 'CS', 'SR',
56
+ 'SO4', 'PO4', 'BO3', 'NO3', 'CO3', 'HCO3', 'OH', 'OXY', 'PER', 'SUL', 'MOO',
57
+ 'GOL', 'EDO', 'PEG', 'PG4', 'PE4', 'PGE', 'MPD', 'BME', 'DTT', 'MRD', 'BU3', 'P6G', 'BTB', 'EPE', 'HEPES',
58
+ 'TRS', 'TRIS', 'ACT', 'ACE', 'FMT', 'EOH', 'IPA', 'DMS', 'DMF', 'MES', 'BES', 'GLY', 'CIT', 'MLT', 'TLA',
59
+ 'NH4', 'PYR', 'HED', 'BNG', 'LMT', 'OLA', 'OLB', 'OLC',
60
+ ]);
61
+ /** Find the largest non-filler HETATM residue and return its centroid. Returns null if none. */
62
+ function autoDetectSite(pdbContent) {
63
+ const groups = new Map();
64
+ for (const line of pdbContent.split('\n')) {
65
+ if (!line.startsWith('HETATM'))
66
+ continue;
67
+ const resname = line.slice(17, 20).trim();
68
+ if (FILLER_HETATMS.has(resname.toUpperCase()))
69
+ continue;
70
+ const chain = line.slice(21, 22).trim() || ' ';
71
+ const resnum = parseInt(line.slice(22, 26).trim(), 10);
72
+ const x = parseFloat(line.slice(30, 38));
73
+ const y = parseFloat(line.slice(38, 46));
74
+ const z = parseFloat(line.slice(46, 54));
75
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
76
+ continue;
77
+ const key = `${resname}|${chain}|${resnum}`;
78
+ let g = groups.get(key);
79
+ if (!g) {
80
+ g = { resname, chain, resnum, xs: [], ys: [], zs: [] };
81
+ groups.set(key, g);
82
+ }
83
+ g.xs.push(x);
84
+ g.ys.push(y);
85
+ g.zs.push(z);
86
+ }
87
+ let best = null;
88
+ for (const g of groups.values()) {
89
+ if (!best || g.xs.length > best.xs.length)
90
+ best = g;
91
+ }
92
+ // A real ligand has ≥5 heavy atoms; anything smaller is likely a stray modification.
93
+ if (!best || best.xs.length < 5)
94
+ return null;
95
+ const n = best.xs.length;
96
+ const mean = (a) => Math.round((a.reduce((s, v) => s + v, 0) / n) * 1000) / 1000;
97
+ return {
98
+ resname: best.resname, chain: best.chain, resnum: best.resnum,
99
+ center: { x: mean(best.xs), y: mean(best.ys), z: mean(best.zs) },
100
+ atomCount: n,
101
+ };
102
+ }
103
+ /**
104
+ * Resolve the binding site for a docking job.
105
+ * Throws on explicit (0,0,0) (a known agent placeholder). When any coord is
106
+ * missing, runs autoDetectSite() on the PDB content; logs what was picked to
107
+ * stderr so the agent sees the decision.
108
+ */
109
+ function resolveSite(pdbContent, pdbLabel, cx, cy, cz) {
110
+ const allSet = [cx, cy, cz].every(v => typeof v === 'number' && !Number.isNaN(v));
111
+ const allZero = allSet && cx === 0 && cy === 0 && cz === 0;
112
+ if (allZero) {
113
+ throw new Error(`Binding site (0,0,0) is not a valid center. To find real coordinates, ` +
114
+ `check the co-crystallized ligand at https://rcsb.org/structure/${pdbLabel}/ligands, ` +
115
+ `or omit --cx/--cy/--cz to auto-detect from the PDB.`);
116
+ }
117
+ if (allSet)
118
+ return { x: cx, y: cy, z: cz };
119
+ const site = autoDetectSite(pdbContent);
120
+ if (!site) {
121
+ throw new Error(`Could not auto-detect a binding site in ${pdbLabel} (no co-crystallized ligand ` +
122
+ `with ≥5 heavy atoms). Specify --cx/--cy/--cz manually — check ligands at ` +
123
+ `https://rcsb.org/structure/${pdbLabel}/ligands.`);
124
+ }
125
+ process.stderr.write(`Auto-detected binding site at HETATM ${site.resname} ${site.chain}${site.resnum} ` +
126
+ `(${site.atomCount} atoms): center=(${site.center.x}, ${site.center.y}, ${site.center.z})\n`);
127
+ return site.center;
128
+ }
129
+ /** Shared binding site options for site-directed docking (Vina/GNINA).
130
+ * Coords are OPTIONAL — if omitted, the CLI auto-detects from the PDB. */
38
131
  function addSiteOptions(cmd) {
39
132
  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)
133
+ .option('--cx <x>', 'Binding site center X (omit to auto-detect)', parseFloat)
134
+ .option('--cy <y>', 'Binding site center Y (omit to auto-detect)', parseFloat)
135
+ .option('--cz <z>', 'Binding site center Z (omit to auto-detect)', parseFloat)
43
136
  .option('--sx <x>', 'Search box size X (default: 20)', parseFloat)
44
137
  .option('--sy <y>', 'Search box size Y (default: 20)', parseFloat)
45
138
  .option('--sz <z>', 'Search box size Z (default: 20)', parseFloat);
@@ -73,7 +166,7 @@ export function registerDockingCommands(program) {
73
166
  const project = requireProject();
74
167
  const client = createClient();
75
168
  const consoleUrl = getConsoleUrl();
76
- const { content: pdbContent } = resolvePdb(pdb);
169
+ const { content: pdbContent } = await resolvePdb(pdb);
77
170
  const result = await client.createDiffDockJob({
78
171
  project_id: project.id,
79
172
  ligand_smiles: smiles,
@@ -106,7 +199,8 @@ async function runSiteDock(smiles, pdb, method, opts) {
106
199
  const project = requireProject();
107
200
  const client = createClient();
108
201
  const consoleUrl = getConsoleUrl();
109
- const { content: pdbContent, label: pdbLabel } = resolvePdb(pdb);
202
+ const { content: pdbContent, label: pdbLabel } = await resolvePdb(pdb);
203
+ const site = resolveSite(pdbContent, pdbLabel, opts.cx, opts.cy, opts.cz);
110
204
  const title = `Dock ${smiles.substring(0, 30)} → ${pdbLabel}`;
111
205
  const result = await client.createDockingJob({
112
206
  project_id: project.id,
@@ -115,9 +209,9 @@ async function runSiteDock(smiles, pdb, method, opts) {
115
209
  ligand_smiles: smiles,
116
210
  scoring_function: method,
117
211
  exhaustiveness: opts.exhaustiveness,
118
- center_x: opts.cx,
119
- center_y: opts.cy,
120
- center_z: opts.cz,
212
+ center_x: site.x,
213
+ center_y: site.y,
214
+ center_z: site.z,
121
215
  size_x: opts.sx,
122
216
  size_y: opts.sy,
123
217
  size_z: opts.sz,
@@ -133,7 +227,8 @@ async function runBatchDock(file, pdb, method, opts) {
133
227
  const client = createClient();
134
228
  const consoleUrl = getConsoleUrl();
135
229
  const compounds = parseCompoundsFile(file);
136
- const { content: pdbContent, label: pdbLabel } = resolvePdb(pdb);
230
+ const { content: pdbContent, label: pdbLabel } = await resolvePdb(pdb);
231
+ const site = resolveSite(pdbContent, pdbLabel, opts.cx, opts.cy, opts.cz);
137
232
  const title = `Batch ${method.toUpperCase()} ${compounds.length} compounds → ${pdbLabel}`;
138
233
  const result = await client.createBatchDockingJob({
139
234
  project_id: project.id,
@@ -141,9 +236,9 @@ async function runBatchDock(file, pdb, method, opts) {
141
236
  protein_pdb: pdbContent,
142
237
  ligand_smiles_list: compounds.map(c => c.smiles),
143
238
  scoring_function: method,
144
- center_x: opts.cx,
145
- center_y: opts.cy,
146
- center_z: opts.cz,
239
+ center_x: site.x,
240
+ center_y: site.y,
241
+ center_z: site.z,
147
242
  size_x: opts.sx,
148
243
  size_y: opts.sy,
149
244
  size_z: opts.sz,
package/dist/types.d.ts CHANGED
@@ -163,6 +163,11 @@ export interface CreditQuota {
163
163
  is_quota_exceeded: boolean;
164
164
  last_used?: string | null;
165
165
  recent_events?: CreditUsageLogEntry[];
166
+ trial_ends_at?: string | null;
167
+ has_access?: boolean;
168
+ in_trial?: boolean;
169
+ trial_days_remaining?: number | null;
170
+ topup_locked?: boolean;
166
171
  }
167
172
  export interface CreditLogResponse {
168
173
  count: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "m3triq",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "M3TRIQ — protein-ligand analysis from the terminal",
5
5
  "type": "module",
6
6
  "bin": {