@llmindset/hf-mcp 0.2.32 → 0.2.34

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.
Files changed (91) hide show
  1. package/dist/docs-search/docs-semantic-search.d.ts +2 -2
  2. package/dist/docs-search/docs-semantic-search.d.ts.map +1 -1
  3. package/dist/docs-search/docs-semantic-search.js +56 -21
  4. package/dist/docs-search/docs-semantic-search.js.map +1 -1
  5. package/dist/hf-api-call.d.ts.map +1 -1
  6. package/dist/hf-api-call.js +4 -0
  7. package/dist/hf-api-call.js.map +1 -1
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/jobs/api-client.d.ts +19 -0
  13. package/dist/jobs/api-client.d.ts.map +1 -0
  14. package/dist/jobs/api-client.js +104 -0
  15. package/dist/jobs/api-client.js.map +1 -0
  16. package/dist/jobs/commands/inspect.d.ts +5 -0
  17. package/dist/jobs/commands/inspect.d.ts.map +1 -0
  18. package/dist/jobs/commands/inspect.js +21 -0
  19. package/dist/jobs/commands/inspect.js.map +1 -0
  20. package/dist/jobs/commands/logs.d.ts +4 -0
  21. package/dist/jobs/commands/logs.d.ts.map +1 -0
  22. package/dist/jobs/commands/logs.js +24 -0
  23. package/dist/jobs/commands/logs.js.map +1 -0
  24. package/dist/jobs/commands/ps.d.ts +4 -0
  25. package/dist/jobs/commands/ps.d.ts.map +1 -0
  26. package/dist/jobs/commands/ps.js +23 -0
  27. package/dist/jobs/commands/ps.js.map +1 -0
  28. package/dist/jobs/commands/run.d.ts +5 -0
  29. package/dist/jobs/commands/run.d.ts.map +1 -0
  30. package/dist/jobs/commands/run.js +89 -0
  31. package/dist/jobs/commands/run.js.map +1 -0
  32. package/dist/jobs/commands/scheduled.d.ts +10 -0
  33. package/dist/jobs/commands/scheduled.d.ts.map +1 -0
  34. package/dist/jobs/commands/scheduled.js +111 -0
  35. package/dist/jobs/commands/scheduled.js.map +1 -0
  36. package/dist/jobs/commands/utils.d.ts +19 -0
  37. package/dist/jobs/commands/utils.d.ts.map +1 -0
  38. package/dist/jobs/commands/utils.js +99 -0
  39. package/dist/jobs/commands/utils.js.map +1 -0
  40. package/dist/jobs/formatters.d.ts +6 -0
  41. package/dist/jobs/formatters.d.ts.map +1 -0
  42. package/dist/jobs/formatters.js +98 -0
  43. package/dist/jobs/formatters.js.map +1 -0
  44. package/dist/jobs/sse-handler.d.ts +12 -0
  45. package/dist/jobs/sse-handler.d.ts.map +1 -0
  46. package/dist/jobs/sse-handler.js +80 -0
  47. package/dist/jobs/sse-handler.js.map +1 -0
  48. package/dist/jobs/tool.d.ts +35 -0
  49. package/dist/jobs/tool.d.ts.map +1 -0
  50. package/dist/jobs/tool.js +333 -0
  51. package/dist/jobs/tool.js.map +1 -0
  52. package/dist/jobs/types.d.ts +295 -0
  53. package/dist/jobs/types.d.ts.map +1 -0
  54. package/dist/jobs/types.js +95 -0
  55. package/dist/jobs/types.js.map +1 -0
  56. package/dist/tool-ids.d.ts +3 -2
  57. package/dist/tool-ids.d.ts.map +1 -1
  58. package/dist/tool-ids.js +10 -2
  59. package/dist/tool-ids.js.map +1 -1
  60. package/dist/types/tool-result.d.ts +1 -0
  61. package/dist/types/tool-result.d.ts.map +1 -1
  62. package/dist/use-space.d.ts +0 -1
  63. package/dist/use-space.d.ts.map +1 -1
  64. package/dist/use-space.js +0 -1
  65. package/dist/use-space.js.map +1 -1
  66. package/package.json +4 -2
  67. package/src/docs-search/docs-semantic-search.ts +71 -20
  68. package/src/hf-api-call.ts +6 -0
  69. package/src/index.ts +1 -0
  70. package/src/jobs/api-client.ts +195 -0
  71. package/src/jobs/commands/inspect.ts +38 -0
  72. package/src/jobs/commands/logs.ts +36 -0
  73. package/src/jobs/commands/ps.ts +40 -0
  74. package/src/jobs/commands/run.ts +134 -0
  75. package/src/jobs/commands/scheduled.ts +189 -0
  76. package/src/jobs/commands/utils.ts +158 -0
  77. package/src/jobs/formatters.ts +149 -0
  78. package/src/jobs/sse-handler.ts +144 -0
  79. package/src/jobs/tool.ts +435 -0
  80. package/src/jobs/types.ts +237 -0
  81. package/src/tool-ids.ts +11 -1
  82. package/src/types/tool-result.ts +6 -0
  83. package/src/use-space.ts +0 -1
  84. package/test/jobs/command-translation.spec.ts +277 -0
  85. package/test/jobs/formatters.spec.ts +267 -0
  86. package/test/jobs/uv-command.spec.ts +81 -0
  87. package/dist/types/mcp-ui-server-shim.d.ts +0 -37
  88. package/dist/types/mcp-ui-server-shim.d.ts.map +0 -1
  89. package/dist/types/mcp-ui-server-shim.js +0 -2
  90. package/dist/types/mcp-ui-server-shim.js.map +0 -1
  91. package/src/types/mcp-ui-server-shim.ts +0 -35
@@ -0,0 +1,38 @@
1
+ import type { InspectArgs, CancelArgs } from '../types.js';
2
+ import type { JobsApiClient } from '../api-client.js';
3
+ import { formatJobDetails } from '../formatters.js';
4
+
5
+ /**
6
+ * Execute the 'inspect' command
7
+ * Gets detailed information about one or more jobs
8
+ */
9
+ export async function inspectCommand(args: InspectArgs, client: JobsApiClient): Promise<string> {
10
+ const jobIds = Array.isArray(args.job_id) ? args.job_id : [args.job_id];
11
+
12
+ // Fetch all jobs
13
+ const jobs = await Promise.all(
14
+ jobIds.map(async (id) => {
15
+ try {
16
+ return await client.getJob(id, args.namespace);
17
+ } catch (error) {
18
+ throw new Error(`Failed to fetch job ${id}: ${(error as Error).message}`);
19
+ }
20
+ })
21
+ );
22
+
23
+ const formattedDetails = formatJobDetails(jobs);
24
+
25
+ return `**Job Details** (${jobs.length} job${jobs.length > 1 ? 's' : ''}):\n\n${formattedDetails}`;
26
+ }
27
+
28
+ /**
29
+ * Execute the 'cancel' command
30
+ * Cancels a running job
31
+ */
32
+ export async function cancelCommand(args: CancelArgs, client: JobsApiClient): Promise<string> {
33
+ await client.cancelJob(args.job_id, args.namespace);
34
+
35
+ return `✓ Job ${args.job_id} has been cancelled.
36
+
37
+ To verify: \`hf_jobs("inspect", {"job_id": "${args.job_id}"})\``;
38
+ }
@@ -0,0 +1,36 @@
1
+ import type { LogsArgs } from '../types.js';
2
+ import type { JobsApiClient } from '../api-client.js';
3
+ import { fetchJobLogs } from '../sse-handler.js';
4
+
5
+ /**
6
+ * Execute the 'logs' command
7
+ * Fetches logs from a job via SSE
8
+ */
9
+ export async function logsCommand(args: LogsArgs, client: JobsApiClient, token?: string): Promise<string> {
10
+ // Get namespace for the logs URL
11
+ const namespace = await client.getNamespace(args.namespace);
12
+ const logsUrl = client.getLogsUrl(args.job_id, namespace);
13
+
14
+ // Fetch logs with timeout and line limit
15
+ const result = await fetchJobLogs(logsUrl, {
16
+ token,
17
+ maxDuration: 10000,
18
+ maxLines: args.tail,
19
+ });
20
+
21
+ if (result.logs.length === 0) {
22
+ return `No logs available for job ${args.job_id}`;
23
+ }
24
+
25
+ let response = `**Logs for job ${args.job_id}** (last ${args.tail} lines):\n\n${'```'}\n`;
26
+ response += result.logs.join('\n');
27
+ response += `\n${'```'}`;
28
+
29
+ if (result.finished) {
30
+ response += '\n\n✓ Job finished.';
31
+ } else if (result.truncated) {
32
+ response += '\n\n⚠ Log collection stopped after 10 seconds. Job may still be running.';
33
+ }
34
+
35
+ return response;
36
+ }
@@ -0,0 +1,40 @@
1
+ import type { PsArgs } from '../types.js';
2
+ import type { JobsApiClient } from '../api-client.js';
3
+ import { formatJobsTable } from '../formatters.js';
4
+
5
+ /**
6
+ * Execute the 'ps' command
7
+ * Lists jobs with optional filtering
8
+ */
9
+ export async function psCommand(args: PsArgs, client: JobsApiClient): Promise<string> {
10
+ // Fetch all jobs from API
11
+ const allJobs = await client.listJobs(args.namespace);
12
+
13
+ // Filter jobs
14
+ let jobs = allJobs;
15
+
16
+ // Default: show only running jobs unless --all is specified
17
+ if (!args.all) {
18
+ jobs = jobs.filter((job) => job.status.stage === 'RUNNING');
19
+ }
20
+
21
+ // Apply status filter if specified
22
+ if (args.status) {
23
+ const statusFilter = args.status.toUpperCase();
24
+ jobs = jobs.filter((job) => job.status.stage.toUpperCase().includes(statusFilter));
25
+ }
26
+
27
+ // Format as markdown table
28
+ const table = formatJobsTable(jobs);
29
+
30
+ if (jobs.length === 0) {
31
+ if (args.all) {
32
+ return 'No jobs found.';
33
+ }
34
+ return 'No running jobs found. Use `{"all": true}` to show all jobs.';
35
+ }
36
+
37
+ return `**Jobs (${jobs.length} of ${allJobs.length} total):**
38
+
39
+ ${table}`;
40
+ }
@@ -0,0 +1,134 @@
1
+ import type { RunArgs, UvArgs } from '../types.js';
2
+ import type { JobsApiClient } from '../api-client.js';
3
+ import { createJobSpec } from './utils.js';
4
+ import { fetchJobLogs } from '../sse-handler.js';
5
+
6
+ /**
7
+ * Execute the 'run' command
8
+ * Creates and runs a job, optionally waiting for logs
9
+ */
10
+ export async function runCommand(args: RunArgs, client: JobsApiClient, token?: string): Promise<string> {
11
+ // Create job spec from args
12
+ const jobSpec = createJobSpec({
13
+ image: args.image,
14
+ command: args.command,
15
+ flavor: args.flavor,
16
+ env: args.env,
17
+ secrets: args.secrets,
18
+ timeout: args.timeout,
19
+ });
20
+
21
+ // Submit job
22
+ const job = await client.runJob(jobSpec, args.namespace);
23
+
24
+ const jobUrl = `https://huggingface.co/jobs/${job.owner.name}/${job.id}`;
25
+
26
+ // If detached, return immediately
27
+ if (args.detach) {
28
+ return `Job started successfully!
29
+
30
+ **Job ID:** ${job.id}
31
+ **Status:** ${job.status.stage}
32
+ **View at:** ${jobUrl}
33
+
34
+ To check logs: \`hf_jobs("logs", {"job_id": "${job.id}"})\`
35
+ To inspect: \`hf_jobs("inspect", {"job_id": "${job.id}"})\``;
36
+ }
37
+
38
+ // Not detached - fetch logs
39
+ const logsUrl = client.getLogsUrl(job.id, job.owner.name);
40
+ const logResult = await fetchJobLogs(logsUrl, { token, maxDuration: 10000, maxLines: 20 });
41
+
42
+ let response = `Job started: ${job.id}\n\n`;
43
+
44
+ if (logResult.logs.length > 0) {
45
+ response += '**Logs (last 20 lines):**\n```\n';
46
+ response += logResult.logs.join('\n');
47
+ response += '\n```\n\n';
48
+ }
49
+
50
+ if (logResult.finished) {
51
+ response += `Job finished. Full details: ${jobUrl}`;
52
+ } else if (logResult.truncated) {
53
+ response += `Log collection stopped after 10s. Job may still be running.\n`;
54
+ response += `View full logs: ${jobUrl}`;
55
+ }
56
+
57
+ return response;
58
+ }
59
+
60
+ /**
61
+ * Execute the 'uv' command
62
+ * Creates and runs a UV-based Python job
63
+ */
64
+ export async function uvCommand(args: UvArgs, client: JobsApiClient, token?: string): Promise<string> {
65
+ // UV jobs use a standard UV image unless overridden
66
+ const image = 'ghcr.io/astral-sh/uv:latest'; // Standard UV image
67
+
68
+ // Detect script source and build command
69
+ const scriptSource = args.script;
70
+ let command: string | string[];
71
+
72
+ // Check if script is a URL
73
+ if (scriptSource.startsWith('http://') || scriptSource.startsWith('https://')) {
74
+ // URL - download and run
75
+ command = buildUvCommand(scriptSource, args);
76
+ } else if (scriptSource.includes('\n')) {
77
+ // Inline multi-line script - encode it
78
+ const encoded = Buffer.from(scriptSource).toString('base64');
79
+ const depsPart =
80
+ args.with_deps && args.with_deps.length > 0
81
+ ? args.with_deps.map(dep => `--with ${dep}`).join(' ')
82
+ : '';
83
+ const pythonPart = args.python ? `-p ${args.python}` : '';
84
+ const uvArgs = [depsPart, pythonPart].filter(Boolean).join(' ');
85
+ const shellSnippet = `echo "${encoded}" | base64 -d | uv run${uvArgs ? ` ${uvArgs}` : ''} -`;
86
+ command = ['/bin/sh', '-lc', shellSnippet];
87
+ } else {
88
+ // Assume it's a URL or path - UV will handle it
89
+ command = buildUvCommand(scriptSource, args);
90
+ }
91
+
92
+ // Convert to run args
93
+ const runArgs: RunArgs = {
94
+ image,
95
+ command,
96
+ flavor: args.flavor,
97
+ env: args.env,
98
+ secrets: args.secrets,
99
+ timeout: args.timeout,
100
+ detach: args.detach,
101
+ namespace: args.namespace,
102
+ };
103
+
104
+ return runCommand(runArgs, client, token);
105
+ }
106
+
107
+ /**
108
+ * Build UV command with options
109
+ */
110
+ function buildUvCommand(script: string, args: UvArgs): string {
111
+ const parts: string[] = ['uv', 'run'];
112
+
113
+ // Add dependencies
114
+ if (args.with_deps && args.with_deps.length > 0) {
115
+ for (const dep of args.with_deps) {
116
+ parts.push('--with', dep);
117
+ }
118
+ }
119
+
120
+ // Add Python version
121
+ if (args.python) {
122
+ parts.push('-p', args.python);
123
+ }
124
+
125
+ // Add script
126
+ parts.push(script);
127
+
128
+ // Add script arguments
129
+ if (args.script_args && args.script_args.length > 0) {
130
+ parts.push(...args.script_args);
131
+ }
132
+
133
+ return parts.join(' ');
134
+ }
@@ -0,0 +1,189 @@
1
+ import type {
2
+ ScheduledRunArgs,
3
+ ScheduledUvArgs,
4
+ ScheduledPsArgs,
5
+ ScheduledJobArgs,
6
+ ScheduledJobSpec,
7
+ } from '../types.js';
8
+ import type { JobsApiClient } from '../api-client.js';
9
+ import { formatScheduledJobsTable, formatScheduledJobDetails } from '../formatters.js';
10
+ import { createJobSpec } from './utils.js';
11
+
12
+ /**
13
+ * Execute 'scheduled run' command
14
+ * Creates a scheduled job
15
+ */
16
+ export async function scheduledRunCommand(args: ScheduledRunArgs, client: JobsApiClient): Promise<string> {
17
+ // Create job spec
18
+ const jobSpec = createJobSpec({
19
+ image: args.image,
20
+ command: args.command,
21
+ flavor: args.flavor,
22
+ env: args.env,
23
+ secrets: args.secrets,
24
+ timeout: args.timeout,
25
+ });
26
+
27
+ // Create scheduled job spec
28
+ const scheduledSpec: ScheduledJobSpec = {
29
+ schedule: args.schedule,
30
+ suspend: args.suspend,
31
+ jobSpec,
32
+ };
33
+
34
+ // Submit scheduled job
35
+ const scheduledJob = await client.createScheduledJob(scheduledSpec, args.namespace);
36
+
37
+ return `✓ Scheduled job created successfully!
38
+
39
+ **Scheduled Job ID:** ${scheduledJob.id}
40
+ **Schedule:** ${scheduledJob.schedule}
41
+ **Suspended:** ${scheduledJob.suspend ? 'Yes' : 'No'}
42
+ **Next Run:** ${scheduledJob.nextRun || 'N/A'}
43
+
44
+ To inspect: \`hf_jobs("scheduled inspect", {"scheduled_job_id": "${scheduledJob.id}"})\`
45
+ To list all: \`hf_jobs("scheduled ps")\``;
46
+ }
47
+
48
+ /**
49
+ * Execute 'scheduled uv' command
50
+ * Creates a scheduled UV job
51
+ */
52
+ export async function scheduledUvCommand(args: ScheduledUvArgs, client: JobsApiClient): Promise<string> {
53
+ // For UV, use standard UV image
54
+ const image = 'ghcr.io/astral-sh/uv:python3.12-bookworm';
55
+
56
+ // Build UV command (similar to regular uv command)
57
+ const scriptSource = args.script;
58
+ let command: string;
59
+
60
+ if (scriptSource.startsWith('http://') || scriptSource.startsWith('https://')) {
61
+ command = buildUvCommand(scriptSource, args);
62
+ } else if (scriptSource.includes('\n')) {
63
+ const encoded = Buffer.from(scriptSource).toString('base64');
64
+ const deps =
65
+ args.with_deps && args.with_deps.length > 0 ? args.with_deps.map((dep) => `--with ${dep}`).join(' ') + ' ' : '';
66
+ command = `echo "${encoded}" | base64 -d | uv run ${deps}${args.python ? `-p ${args.python}` : ''} -`;
67
+ } else {
68
+ command = buildUvCommand(scriptSource, args);
69
+ }
70
+
71
+ // Convert to scheduled run args
72
+ const scheduledRunArgs: ScheduledRunArgs = {
73
+ schedule: args.schedule,
74
+ suspend: args.suspend,
75
+ image,
76
+ command,
77
+ flavor: args.flavor,
78
+ env: args.env,
79
+ secrets: args.secrets,
80
+ timeout: args.timeout,
81
+ detach: args.detach,
82
+ namespace: args.namespace,
83
+ };
84
+
85
+ return scheduledRunCommand(scheduledRunArgs, client);
86
+ }
87
+
88
+ /**
89
+ * Build UV command with options
90
+ */
91
+ function buildUvCommand(
92
+ script: string,
93
+ args: { with_deps?: string[]; python?: string; script_args?: string[] }
94
+ ): string {
95
+ const parts: string[] = ['uv', 'run'];
96
+
97
+ if (args.with_deps && args.with_deps.length > 0) {
98
+ for (const dep of args.with_deps) {
99
+ parts.push('--with', dep);
100
+ }
101
+ }
102
+
103
+ if (args.python) {
104
+ parts.push('-p', args.python);
105
+ }
106
+
107
+ parts.push(script);
108
+
109
+ if (args.script_args && args.script_args.length > 0) {
110
+ parts.push(...args.script_args);
111
+ }
112
+
113
+ return parts.join(' ');
114
+ }
115
+
116
+ /**
117
+ * Execute 'scheduled ps' command
118
+ * Lists scheduled jobs
119
+ */
120
+ export async function scheduledPsCommand(args: ScheduledPsArgs, client: JobsApiClient): Promise<string> {
121
+ // Fetch all scheduled jobs
122
+ const allJobs = await client.listScheduledJobs(args.namespace);
123
+
124
+ // Filter jobs
125
+ let jobs = allJobs;
126
+
127
+ // Default: hide suspended jobs unless --all is specified
128
+ if (!args.all) {
129
+ jobs = jobs.filter((job) => !job.suspend);
130
+ }
131
+
132
+ // Format as markdown table
133
+ const table = formatScheduledJobsTable(jobs);
134
+
135
+ if (jobs.length === 0) {
136
+ if (args.all) {
137
+ return 'No scheduled jobs found.';
138
+ }
139
+ return 'No active scheduled jobs found. Use `{"all": true}` to show suspended jobs.';
140
+ }
141
+
142
+ return `**Scheduled Jobs (${jobs.length} of ${allJobs.length} total):**
143
+
144
+ ${table}`;
145
+ }
146
+
147
+ /**
148
+ * Execute 'scheduled inspect' command
149
+ * Gets details of a scheduled job
150
+ */
151
+ export async function scheduledInspectCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
152
+ const job = await client.getScheduledJob(args.scheduled_job_id, args.namespace);
153
+ const formattedDetails = formatScheduledJobDetails(job);
154
+ return `**Scheduled Job Details:**\n\n${formattedDetails}`;
155
+ }
156
+
157
+ /**
158
+ * Execute 'scheduled delete' command
159
+ * Deletes a scheduled job
160
+ */
161
+ export async function scheduledDeleteCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
162
+ await client.deleteScheduledJob(args.scheduled_job_id, args.namespace);
163
+
164
+ return `✓ Scheduled job ${args.scheduled_job_id} has been deleted.`;
165
+ }
166
+
167
+ /**
168
+ * Execute 'scheduled suspend' command
169
+ * Suspends a scheduled job
170
+ */
171
+ export async function scheduledSuspendCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
172
+ await client.suspendScheduledJob(args.scheduled_job_id, args.namespace);
173
+
174
+ return `✓ Scheduled job ${args.scheduled_job_id} has been suspended.
175
+
176
+ To resume: \`hf_jobs("scheduled resume", {"scheduled_job_id": "${args.scheduled_job_id}"})\``;
177
+ }
178
+
179
+ /**
180
+ * Execute 'scheduled resume' command
181
+ * Resumes a suspended scheduled job
182
+ */
183
+ export async function scheduledResumeCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
184
+ await client.resumeScheduledJob(args.scheduled_job_id, args.namespace);
185
+
186
+ return `✓ Scheduled job ${args.scheduled_job_id} has been resumed.
187
+
188
+ To inspect: \`hf_jobs("scheduled inspect", {"scheduled_job_id": "${args.scheduled_job_id}"})\``;
189
+ }
@@ -0,0 +1,158 @@
1
+ import type { JobSpec } from '../types.js';
2
+ import { parse as parseShellArgs } from 'shell-quote';
3
+
4
+ interface EnvToken {
5
+ type: 'env';
6
+ key: string;
7
+ }
8
+
9
+ const SPECIAL_PARAMS = new Set(['*', '@', '#', '?', '!', '-', '_']);
10
+
11
+ function isEnvToken(entry: unknown): entry is EnvToken {
12
+ return Boolean(entry && typeof entry === 'object' && (entry as EnvToken).type === 'env');
13
+ }
14
+
15
+ function formatEnvReference(key: string): string {
16
+ if (key === '') {
17
+ return '$';
18
+ }
19
+
20
+ if (key === '$') {
21
+ return '$$';
22
+ }
23
+
24
+ if (/^[A-Za-z0-9_]+$/.test(key)) {
25
+ return `$${key}`;
26
+ }
27
+
28
+ if (SPECIAL_PARAMS.has(key)) {
29
+ return `$${key}`;
30
+ }
31
+
32
+ return `\${${key}}`;
33
+ }
34
+
35
+ /**
36
+ * Parse timeout string (e.g., "5m", "2h", "30s") to seconds
37
+ */
38
+ export function parseTimeout(timeout: string): number {
39
+ const timeUnits: Record<'s' | 'm' | 'h' | 'd', number> = {
40
+ s: 1,
41
+ m: 60,
42
+ h: 3600,
43
+ d: 86400,
44
+ };
45
+
46
+ const match = timeout.match(/^(\d+(?:\.\d+)?)(s|m|h|d)$/);
47
+ if (!match || !match[1] || !match[2]) {
48
+ // Try to parse as plain number (seconds)
49
+ const seconds = parseInt(timeout, 10);
50
+ if (!isNaN(seconds)) {
51
+ return seconds;
52
+ }
53
+ throw new Error(
54
+ `Invalid timeout format: "${timeout}". Use format like "5m", "2h", "30s", or plain seconds.`
55
+ );
56
+ }
57
+
58
+ const value = parseFloat(match[1]);
59
+ const unit = match[2] as 's' | 'm' | 'h' | 'd';
60
+ return Math.floor(value * timeUnits[unit]);
61
+ }
62
+
63
+ /**
64
+ * Detect if image is a Space URL and extract spaceId
65
+ * Returns { dockerImage } or { spaceId }
66
+ */
67
+ export function parseImageSource(image: string): { dockerImage?: string; spaceId?: string } {
68
+ const spacePrefixes = [
69
+ 'https://huggingface.co/spaces/',
70
+ 'https://hf.co/spaces/',
71
+ 'huggingface.co/spaces/',
72
+ 'hf.co/spaces/',
73
+ ];
74
+
75
+ for (const prefix of spacePrefixes) {
76
+ if (image.startsWith(prefix)) {
77
+ return { spaceId: image.substring(prefix.length) };
78
+ }
79
+ }
80
+
81
+ // Not a space, treat as docker image
82
+ return { dockerImage: image };
83
+ }
84
+
85
+ /**
86
+ * Parse command string or array into command array
87
+ * Uses shell-quote library for proper POSIX-compliant parsing
88
+ */
89
+ export function parseCommand(command: string | string[]): { command: string[]; arguments?: string[] } {
90
+ // If already an array, return as-is
91
+ if (Array.isArray(command)) {
92
+ return { command, arguments: [] };
93
+ }
94
+
95
+ // Parse the command string using shell-quote for POSIX-compliant parsing
96
+ const parsed = parseShellArgs<EnvToken>(command, key => ({ type: 'env', key }));
97
+
98
+ // Convert parsed result to string array
99
+ // shell-quote can return various types (strings, objects for operators, etc.)
100
+ // We filter to only keep string arguments
101
+ const stringArgs: string[] = [];
102
+ for (const arg of parsed) {
103
+ if (typeof arg === 'string') {
104
+ stringArgs.push(arg);
105
+ } else if (isEnvToken(arg)) {
106
+ stringArgs.push(formatEnvReference(arg.key));
107
+ } else {
108
+ // If we encounter a non-string (like operators), throw an error
109
+ throw new Error(
110
+ `Unsupported shell syntax in command: "${command}". ` +
111
+ `Please use an array format for commands with complex shell operators, ` +
112
+ `or use simple quoted strings.`
113
+ );
114
+ }
115
+ }
116
+
117
+ if (stringArgs.length === 0) {
118
+ throw new Error(`Invalid command: "${command}". Command cannot be empty.`);
119
+ }
120
+
121
+ return { command: stringArgs, arguments: [] };
122
+ }
123
+
124
+ /**
125
+ * Create a JobSpec from run command arguments
126
+ */
127
+ export function createJobSpec(args: {
128
+ image: string;
129
+ command: string | string[];
130
+ flavor?: string;
131
+ env?: Record<string, string>;
132
+ secrets?: Record<string, string>;
133
+ timeout?: string;
134
+ }): JobSpec {
135
+ // Validate required fields
136
+ if (!args.image) {
137
+ throw new Error('image parameter is required. Provide a Docker image (e.g., "python:3.12") or Space URL.');
138
+ }
139
+ if (!args.command) {
140
+ throw new Error('command parameter is required. Provide a command as string or array.');
141
+ }
142
+
143
+ const imageSource = parseImageSource(args.image);
144
+ const { command, arguments: cmdArgs } = parseCommand(args.command);
145
+ const timeoutSeconds = args.timeout ? parseTimeout(args.timeout) : undefined;
146
+
147
+ const spec: JobSpec = {
148
+ ...imageSource,
149
+ command,
150
+ arguments: cmdArgs,
151
+ flavor: args.flavor || 'cpu-basic',
152
+ environment: args.env || {}, // API requires object, not undefined
153
+ secrets: args.secrets || {}, // Same for secrets
154
+ timeoutSeconds,
155
+ };
156
+
157
+ return spec;
158
+ }