@llmindset/hf-mcp 0.2.33 → 0.2.35

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 (79) hide show
  1. package/dist/docs-search/docs-semantic-search.js +1 -1
  2. package/dist/docs-search/docs-semantic-search.js.map +1 -1
  3. package/dist/hf-api-call.d.ts.map +1 -1
  4. package/dist/hf-api-call.js +4 -0
  5. package/dist/hf-api-call.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/jobs/api-client.d.ts +19 -0
  11. package/dist/jobs/api-client.d.ts.map +1 -0
  12. package/dist/jobs/api-client.js +95 -0
  13. package/dist/jobs/api-client.js.map +1 -0
  14. package/dist/jobs/commands/inspect.d.ts +5 -0
  15. package/dist/jobs/commands/inspect.d.ts.map +1 -0
  16. package/dist/jobs/commands/inspect.js +21 -0
  17. package/dist/jobs/commands/inspect.js.map +1 -0
  18. package/dist/jobs/commands/logs.d.ts +4 -0
  19. package/dist/jobs/commands/logs.d.ts.map +1 -0
  20. package/dist/jobs/commands/logs.js +24 -0
  21. package/dist/jobs/commands/logs.js.map +1 -0
  22. package/dist/jobs/commands/ps.d.ts +4 -0
  23. package/dist/jobs/commands/ps.d.ts.map +1 -0
  24. package/dist/jobs/commands/ps.js +23 -0
  25. package/dist/jobs/commands/ps.js.map +1 -0
  26. package/dist/jobs/commands/run.d.ts +5 -0
  27. package/dist/jobs/commands/run.d.ts.map +1 -0
  28. package/dist/jobs/commands/run.js +90 -0
  29. package/dist/jobs/commands/run.js.map +1 -0
  30. package/dist/jobs/commands/scheduled.d.ts +10 -0
  31. package/dist/jobs/commands/scheduled.d.ts.map +1 -0
  32. package/dist/jobs/commands/scheduled.js +112 -0
  33. package/dist/jobs/commands/scheduled.js.map +1 -0
  34. package/dist/jobs/commands/utils.d.ts +20 -0
  35. package/dist/jobs/commands/utils.d.ts.map +1 -0
  36. package/dist/jobs/commands/utils.js +120 -0
  37. package/dist/jobs/commands/utils.js.map +1 -0
  38. package/dist/jobs/formatters.d.ts +6 -0
  39. package/dist/jobs/formatters.d.ts.map +1 -0
  40. package/dist/jobs/formatters.js +98 -0
  41. package/dist/jobs/formatters.js.map +1 -0
  42. package/dist/jobs/sse-handler.d.ts +12 -0
  43. package/dist/jobs/sse-handler.d.ts.map +1 -0
  44. package/dist/jobs/sse-handler.js +80 -0
  45. package/dist/jobs/sse-handler.js.map +1 -0
  46. package/dist/jobs/tool.d.ts +35 -0
  47. package/dist/jobs/tool.d.ts.map +1 -0
  48. package/dist/jobs/tool.js +333 -0
  49. package/dist/jobs/tool.js.map +1 -0
  50. package/dist/jobs/types.d.ts +295 -0
  51. package/dist/jobs/types.d.ts.map +1 -0
  52. package/dist/jobs/types.js +95 -0
  53. package/dist/jobs/types.js.map +1 -0
  54. package/dist/tool-ids.d.ts +3 -2
  55. package/dist/tool-ids.d.ts.map +1 -1
  56. package/dist/tool-ids.js +10 -2
  57. package/dist/tool-ids.js.map +1 -1
  58. package/dist/types/tool-result.d.ts +1 -0
  59. package/dist/types/tool-result.d.ts.map +1 -1
  60. package/package.json +4 -2
  61. package/src/docs-search/docs-semantic-search.ts +1 -1
  62. package/src/hf-api-call.ts +6 -0
  63. package/src/index.ts +1 -0
  64. package/src/jobs/api-client.ts +187 -0
  65. package/src/jobs/commands/inspect.ts +38 -0
  66. package/src/jobs/commands/logs.ts +36 -0
  67. package/src/jobs/commands/ps.ts +40 -0
  68. package/src/jobs/commands/run.ts +135 -0
  69. package/src/jobs/commands/scheduled.ts +198 -0
  70. package/src/jobs/commands/utils.ts +191 -0
  71. package/src/jobs/formatters.ts +149 -0
  72. package/src/jobs/sse-handler.ts +144 -0
  73. package/src/jobs/tool.ts +435 -0
  74. package/src/jobs/types.ts +237 -0
  75. package/src/tool-ids.ts +11 -1
  76. package/src/types/tool-result.ts +6 -0
  77. package/test/jobs/command-translation.spec.ts +331 -0
  78. package/test/jobs/formatters.spec.ts +267 -0
  79. package/test/jobs/uv-command.spec.ts +81 -0
@@ -0,0 +1,237 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Hardware flavors available for jobs
5
+ */
6
+ export const CPU_FLAVORS = ['cpu-basic', 'cpu-upgrade', 'cpu-performance', 'cpu-xl'] as const;
7
+
8
+ export const GPU_FLAVORS = [
9
+ 'sprx8',
10
+ 'zero-a10g',
11
+ 't4-small',
12
+ 't4-medium',
13
+ 'l4x1',
14
+ 'l4x4',
15
+ 'l40sx1',
16
+ 'l40sx4',
17
+ 'l40sx8',
18
+ 'a10g-small',
19
+ 'a10g-large',
20
+ 'a10g-largex2',
21
+ 'a10g-largex4',
22
+ 'a100-large',
23
+ 'h100',
24
+ 'h100x8',
25
+ ] as const;
26
+
27
+ export const SPECIALIZED_FLAVORS = ['inf2x6'] as const;
28
+
29
+ export const ALL_FLAVORS = [...CPU_FLAVORS, ...GPU_FLAVORS, ...SPECIALIZED_FLAVORS] as const;
30
+
31
+ export type JobFlavor = (typeof ALL_FLAVORS)[number];
32
+
33
+ /**
34
+ * Job status stages (from OpenAPI spec)
35
+ */
36
+ export type JobStage = 'RUNNING' | 'COMPLETED' | 'CANCELED' | 'ERROR' | 'DELETED';
37
+
38
+ /**
39
+ * Job status object from API
40
+ */
41
+ export interface JobStatus {
42
+ stage: JobStage;
43
+ message?: string | null;
44
+ }
45
+
46
+ /**
47
+ * Job owner information
48
+ */
49
+ export interface JobOwner {
50
+ id: string;
51
+ name: string;
52
+ type: 'user' | 'org';
53
+ }
54
+
55
+ /**
56
+ * Job information from API
57
+ * Based on OpenAPI schema
58
+ */
59
+ export interface JobInfo {
60
+ id: string;
61
+ createdAt: string;
62
+ dockerImage?: string;
63
+ spaceId?: string;
64
+ command?: string[];
65
+ arguments?: string[];
66
+ environment: Record<string, string>;
67
+ secrets?: Record<string, string | null>;
68
+ flavor: string;
69
+ status: JobStatus;
70
+ owner: JobOwner;
71
+ createdBy?: JobOwner;
72
+ tags?: string[];
73
+ timeout?: number;
74
+ // Additional fields not in OpenAPI but present in responses
75
+ url?: string;
76
+ endpoint?: string;
77
+ finishedAt?: string;
78
+ }
79
+
80
+ /**
81
+ * Job specification for creating jobs
82
+ */
83
+ export interface JobSpec {
84
+ dockerImage?: string;
85
+ spaceId?: string;
86
+ command: string[];
87
+ arguments?: string[];
88
+ environment?: Record<string, string>;
89
+ secrets?: Record<string, string>;
90
+ flavor: string;
91
+ timeoutSeconds?: number;
92
+ }
93
+
94
+ /**
95
+ * Scheduled job specification
96
+ */
97
+ export interface ScheduledJobSpec {
98
+ schedule: string;
99
+ suspend?: boolean;
100
+ jobSpec: JobSpec;
101
+ }
102
+
103
+ /**
104
+ * Scheduled job information from API
105
+ */
106
+ export interface ScheduledJobInfo {
107
+ id: string;
108
+ schedule: string;
109
+ suspend: boolean;
110
+ jobSpec: JobSpec;
111
+ lastRun?: string;
112
+ nextRun?: string;
113
+ owner: JobOwner;
114
+ createdAt: string;
115
+ }
116
+
117
+ /**
118
+ * Log event from SSE stream
119
+ */
120
+ export interface LogEvent {
121
+ timestamp: string;
122
+ data: string;
123
+ }
124
+
125
+ /**
126
+ * Zod schemas for command arguments
127
+ */
128
+
129
+ // Common args shared across commands
130
+ const commonArgsSchema = z.object({
131
+ namespace: z.string().optional().describe('Target namespace (username or organization). Defaults to current user.'),
132
+ });
133
+
134
+ // Run command args
135
+ export const runArgsSchema = commonArgsSchema.extend({
136
+ image: z.string().describe('Docker image or HF Space URL (e.g., "python:3.12" or "hf.co/spaces/user/space")'),
137
+ command: z
138
+ .union([z.string(), z.array(z.string())])
139
+ .describe(
140
+ 'Command to execute. Array format recommended (e.g., ["python", "script.py"]). ' +
141
+ 'String format is parsed with POSIX shell semantics (quotes, escaping). ' +
142
+ 'For multiline scripts, use array with newlines in arguments.'
143
+ ),
144
+ flavor: z
145
+ .enum(ALL_FLAVORS)
146
+ .optional()
147
+ .default('cpu-basic')
148
+ .describe(`Hardware flavor. Options: ${ALL_FLAVORS.join(', ')}`),
149
+ env: z.record(z.string()).optional().describe('Environment variables as key-value pairs'),
150
+ secrets: z.record(z.string()).optional().describe('Secret environment variables (encrypted server-side)'),
151
+ timeout: z
152
+ .string()
153
+ .optional()
154
+ .describe('Max duration (e.g., "5m", "2h", "30s"). Default: 30m')
155
+ .default('30m'),
156
+ detach: z
157
+ .boolean()
158
+ .optional()
159
+ .default(true)
160
+ .describe('Run in background and return immediately (default: true)'),
161
+ });
162
+
163
+ // UV command args
164
+ export const uvArgsSchema = commonArgsSchema.extend({
165
+ script: z
166
+ .string()
167
+ .describe('Python script: local file path, URL, or inline code. UV will handle dependencies automatically.'),
168
+ repo: z.string().optional().describe('Persistent repository name for script storage'),
169
+ with_deps: z.array(z.string()).optional().describe('Additional package dependencies'),
170
+ script_args: z.array(z.string()).optional().describe('Arguments to pass to the script'),
171
+ python: z.string().optional().describe('Python interpreter version (e.g., "3.12")'),
172
+ flavor: z.enum(ALL_FLAVORS).optional().default('cpu-basic').describe('Hardware flavor'),
173
+ env: z.record(z.string()).optional().describe('Environment variables'),
174
+ secrets: z.record(z.string()).optional().describe('Secret environment variables'),
175
+ timeout: z.string().optional().default('30m').describe('Max duration'),
176
+ detach: z.boolean().optional().default(true).describe('Run in background'),
177
+ });
178
+
179
+ // PS command args
180
+ export const psArgsSchema = commonArgsSchema.extend({
181
+ all: z.boolean().optional().default(false).describe('Show all jobs (default: only running)'),
182
+ status: z.string().optional().describe('Filter by status (e.g., "RUNNING", "COMPLETED")'),
183
+ });
184
+
185
+ // Logs command args
186
+ export const logsArgsSchema = commonArgsSchema.extend({
187
+ job_id: z.string().describe('Job ID to fetch logs from'),
188
+ tail: z.number().optional().default(20).describe('Number of lines to return (default: 20)'),
189
+ });
190
+
191
+ // Inspect command args
192
+ export const inspectArgsSchema = commonArgsSchema.extend({
193
+ job_id: z.union([z.string(), z.array(z.string())]).describe('Job ID(s) to inspect'),
194
+ });
195
+
196
+ // Cancel command args
197
+ export const cancelArgsSchema = commonArgsSchema.extend({
198
+ job_id: z.string().describe('Job ID to cancel'),
199
+ });
200
+
201
+ // Scheduled run args
202
+ export const scheduledRunArgsSchema = runArgsSchema.extend({
203
+ schedule: z
204
+ .string()
205
+ .describe('Schedule: cron expression or shorthand (@hourly, @daily, @weekly, @monthly, @yearly)'),
206
+ suspend: z.boolean().optional().default(false).describe('Create in suspended state'),
207
+ });
208
+
209
+ // Scheduled UV args
210
+ export const scheduledUvArgsSchema = uvArgsSchema.extend({
211
+ schedule: z.string().describe('Schedule: cron expression or shorthand'),
212
+ suspend: z.boolean().optional().default(false).describe('Create in suspended state'),
213
+ });
214
+
215
+ // Scheduled PS args
216
+ export const scheduledPsArgsSchema = commonArgsSchema.extend({
217
+ all: z.boolean().optional().default(false).describe('Show all scheduled jobs (default: hide suspended)'),
218
+ });
219
+
220
+ // Scheduled inspect/delete/suspend/resume args
221
+ export const scheduledJobArgsSchema = commonArgsSchema.extend({
222
+ scheduled_job_id: z.string().describe('Scheduled job ID'),
223
+ });
224
+
225
+ /**
226
+ * Export type aliases for use in commands
227
+ */
228
+ export type RunArgs = z.infer<typeof runArgsSchema>;
229
+ export type UvArgs = z.infer<typeof uvArgsSchema>;
230
+ export type PsArgs = z.infer<typeof psArgsSchema>;
231
+ export type LogsArgs = z.infer<typeof logsArgsSchema>;
232
+ export type InspectArgs = z.infer<typeof inspectArgsSchema>;
233
+ export type CancelArgs = z.infer<typeof cancelArgsSchema>;
234
+ export type ScheduledRunArgs = z.infer<typeof scheduledRunArgsSchema>;
235
+ export type ScheduledUvArgs = z.infer<typeof scheduledUvArgsSchema>;
236
+ export type ScheduledPsArgs = z.infer<typeof scheduledPsArgsSchema>;
237
+ export type ScheduledJobArgs = z.infer<typeof scheduledJobArgsSchema>;
package/src/tool-ids.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  DOCS_SEMANTIC_SEARCH_CONFIG,
22
22
  DOC_FETCH_CONFIG,
23
23
  USE_SPACE_TOOL_CONFIG,
24
+ HF_JOBS_TOOL_CONFIG,
24
25
  } from './index.js';
25
26
 
26
27
  // Extract tool IDs from their configs (single source of truth)
@@ -41,6 +42,7 @@ export const USER_SUMMARY_PROMPT_ID = USER_SUMMARY_PROMPT_CONFIG.name;
41
42
  export const PAPER_SUMMARY_PROMPT_ID = PAPER_SUMMARY_PROMPT_CONFIG.name;
42
43
  export const MODEL_DETAIL_PROMPT_ID = MODEL_DETAIL_PROMPT_CONFIG.name;
43
44
  export const DATASET_DETAIL_PROMPT_ID = DATASET_DETAIL_PROMPT_CONFIG.name;
45
+ export const HF_JOBS_TOOL_ID = HF_JOBS_TOOL_CONFIG.name;
44
46
 
45
47
  // Complete list of all built-in tool IDs
46
48
  export const ALL_BUILTIN_TOOL_IDS = [
@@ -57,6 +59,7 @@ export const ALL_BUILTIN_TOOL_IDS = [
57
59
  DOCS_SEMANTIC_SEARCH_TOOL_ID,
58
60
  DOC_FETCH_TOOL_ID,
59
61
  USE_SPACE_TOOL_ID,
62
+ HF_JOBS_TOOL_ID,
60
63
  ] as const;
61
64
  // Grouped tool IDs for bouquet configurations
62
65
  export const TOOL_ID_GROUPS = {
@@ -67,7 +70,13 @@ export const TOOL_ID_GROUPS = {
67
70
  PAPER_SEARCH_TOOL_ID,
68
71
  DOCS_SEMANTIC_SEARCH_TOOL_ID,
69
72
  ] as const,
70
- spaces: [SPACE_SEARCH_TOOL_ID, DUPLICATE_SPACE_TOOL_ID, SPACE_INFO_TOOL_ID, SPACE_FILES_TOOL_ID, USE_SPACE_TOOL_ID] as const,
73
+ spaces: [
74
+ SPACE_SEARCH_TOOL_ID,
75
+ DUPLICATE_SPACE_TOOL_ID,
76
+ SPACE_INFO_TOOL_ID,
77
+ SPACE_FILES_TOOL_ID,
78
+ USE_SPACE_TOOL_ID,
79
+ ] as const,
71
80
  detail: [MODEL_DETAIL_TOOL_ID, DATASET_DETAIL_TOOL_ID, HUB_INSPECT_TOOL_ID] as const,
72
81
  docs: [DOCS_SEMANTIC_SEARCH_TOOL_ID, DOC_FETCH_TOOL_ID] as const,
73
82
  hf_api: [
@@ -77,6 +86,7 @@ export const TOOL_ID_GROUPS = {
77
86
  PAPER_SEARCH_TOOL_ID,
78
87
  HUB_INSPECT_TOOL_ID,
79
88
  DOCS_SEMANTIC_SEARCH_TOOL_ID,
89
+ // HF_JOBS_TOOL_ID,
80
90
  ] as const,
81
91
  all: [...ALL_BUILTIN_TOOL_IDS] as const,
82
92
  } as const;
@@ -21,4 +21,10 @@ export interface ToolResult {
21
21
  * For prompts: 1 if generated successfully
22
22
  */
23
23
  resultsShared: number;
24
+
25
+ /**
26
+ * Indicates whether this result represents an error condition
27
+ * When true, formatted contains an error message
28
+ */
29
+ isError?: boolean;
24
30
  }
@@ -0,0 +1,331 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseTimeout, parseImageSource, parseCommand, createJobSpec } from '../../src/jobs/commands/utils.js';
3
+
4
+ describe('Jobs Command Translation', () => {
5
+ describe('parseTimeout', () => {
6
+ it('should parse timeout with seconds', () => {
7
+ expect(parseTimeout('30s')).toBe(30);
8
+ expect(parseTimeout('45s')).toBe(45);
9
+ });
10
+
11
+ it('should parse timeout with minutes', () => {
12
+ expect(parseTimeout('5m')).toBe(300);
13
+ expect(parseTimeout('10m')).toBe(600);
14
+ });
15
+
16
+ it('should parse timeout with hours', () => {
17
+ expect(parseTimeout('2h')).toBe(7200);
18
+ expect(parseTimeout('1h')).toBe(3600);
19
+ });
20
+
21
+ it('should parse timeout with days', () => {
22
+ expect(parseTimeout('1d')).toBe(86400);
23
+ });
24
+
25
+ it('should parse plain number as seconds', () => {
26
+ expect(parseTimeout('300')).toBe(300);
27
+ expect(parseTimeout('60')).toBe(60);
28
+ });
29
+
30
+ it('should handle decimal values', () => {
31
+ expect(parseTimeout('1.5h')).toBe(5400);
32
+ expect(parseTimeout('2.5m')).toBe(150);
33
+ });
34
+
35
+ it('should throw error for invalid format', () => {
36
+ expect(() => parseTimeout('invalid')).toThrow(/Invalid timeout format/);
37
+ expect(() => parseTimeout('xyz')).toThrow(/Invalid timeout format/);
38
+ });
39
+
40
+ it('should parse partial numbers (parseInt behavior)', () => {
41
+ // parseInt stops at first non-numeric, so '5x' becomes 5
42
+ expect(parseTimeout('5x')).toBe(5);
43
+ expect(parseTimeout('10abc')).toBe(10);
44
+ });
45
+ });
46
+
47
+ describe('parseImageSource', () => {
48
+ it('should detect Docker Hub images', () => {
49
+ const result = parseImageSource('python:3.12');
50
+ expect(result.dockerImage).toBe('python:3.12');
51
+ expect(result.spaceId).toBeUndefined();
52
+ });
53
+
54
+ it('should detect HuggingFace Space URLs with https://huggingface.co/', () => {
55
+ const result = parseImageSource('https://huggingface.co/spaces/user/app');
56
+ expect(result.spaceId).toBe('user/app');
57
+ expect(result.dockerImage).toBeUndefined();
58
+ });
59
+
60
+ it('should detect HuggingFace Space URLs with https://hf.co/', () => {
61
+ const result = parseImageSource('https://hf.co/spaces/user/app');
62
+ expect(result.spaceId).toBe('user/app');
63
+ expect(result.dockerImage).toBeUndefined();
64
+ });
65
+
66
+ it('should detect HuggingFace Space URLs without https prefix', () => {
67
+ const result = parseImageSource('huggingface.co/spaces/user/app');
68
+ expect(result.spaceId).toBe('user/app');
69
+ expect(result.dockerImage).toBeUndefined();
70
+ });
71
+
72
+ it('should detect HuggingFace Space URLs with hf.co shorthand', () => {
73
+ const result = parseImageSource('hf.co/spaces/user/app');
74
+ expect(result.spaceId).toBe('user/app');
75
+ expect(result.dockerImage).toBeUndefined();
76
+ });
77
+
78
+ it('should treat complex docker images as docker images', () => {
79
+ const result = parseImageSource('ghcr.io/owner/repo:tag');
80
+ expect(result.dockerImage).toBe('ghcr.io/owner/repo:tag');
81
+ expect(result.spaceId).toBeUndefined();
82
+ });
83
+ });
84
+
85
+ describe('parseCommand', () => {
86
+ it('should return array commands as-is', () => {
87
+ const result = parseCommand(['python', '-c', 'print("hello")']);
88
+ expect(result.command).toEqual(['python', '-c', 'print("hello")']);
89
+ });
90
+
91
+ it('should split simple string commands', () => {
92
+ const result = parseCommand('python script.py');
93
+ expect(result.command).toEqual(['python', 'script.py']);
94
+ });
95
+
96
+ it('should handle single word commands', () => {
97
+ const result = parseCommand('ls');
98
+ expect(result.command).toEqual(['ls']);
99
+ });
100
+
101
+ it('should handle double-quoted strings', () => {
102
+ const result = parseCommand('python -c "print(\'Hello world!\')"');
103
+ expect(result.command).toEqual(['python', '-c', "print('Hello world!')"]);
104
+ });
105
+
106
+ it('should handle single-quoted strings', () => {
107
+ const result = parseCommand("python -c 'print(\"Hello world!\")'");
108
+ expect(result.command).toEqual(['python', '-c', 'print("Hello world!")']);
109
+ });
110
+
111
+ it('should handle escaped quotes', () => {
112
+ const result = parseCommand('echo "He said \\"hello\\""');
113
+ expect(result.command).toEqual(['echo', 'He said "hello"']);
114
+ });
115
+
116
+ it('should handle mixed quotes and spaces', () => {
117
+ const result = parseCommand('python -c "print(\'hello world\')"');
118
+ expect(result.command).toEqual(['python', '-c', "print('hello world')"]);
119
+ });
120
+
121
+ it('should handle multiple quoted arguments', () => {
122
+ const result = parseCommand('cmd "arg one" "arg two" "arg three"');
123
+ expect(result.command).toEqual(['cmd', 'arg one', 'arg two', 'arg three']);
124
+ });
125
+
126
+ it('should handle quoted strings with no spaces', () => {
127
+ const result = parseCommand('echo "hello"');
128
+ expect(result.command).toEqual(['echo', 'hello']);
129
+ });
130
+
131
+ it('should handle empty quotes', () => {
132
+ const result = parseCommand('echo "" test');
133
+ expect(result.command).toEqual(['echo', '', 'test']);
134
+ });
135
+
136
+ it('should handle real-world example from docs', () => {
137
+ const result = parseCommand('python -c "print(\'Hello world!\')"');
138
+ expect(result.command).toEqual(['python', '-c', "print('Hello world!')"]);
139
+ });
140
+
141
+ it('should handle commands with multiple spaces', () => {
142
+ const result = parseCommand('python script.py --arg');
143
+ expect(result.command).toEqual(['python', 'script.py', '--arg']);
144
+ });
145
+
146
+ it('should handle commands with tabs', () => {
147
+ const result = parseCommand('python\tscript.py\t--arg');
148
+ expect(result.command).toEqual(['python', 'script.py', '--arg']);
149
+ });
150
+
151
+ it('should handle mixed whitespace', () => {
152
+ const result = parseCommand('cmd \t arg1\t\t arg2');
153
+ expect(result.command).toEqual(['cmd', 'arg1', 'arg2']);
154
+ });
155
+
156
+ it('should handle backslash escaping (POSIX shell semantics)', () => {
157
+ // In POSIX shells, \n in double quotes is literal, not a newline
158
+ const result = parseCommand('echo "hello\\nworld"');
159
+ expect(result.command).toEqual(['echo', 'hello\\nworld']);
160
+ });
161
+
162
+ it('should handle backslash literally in single quotes', () => {
163
+ const result = parseCommand("echo 'hello\\nworld'");
164
+ expect(result.command).toEqual(['echo', 'hello\\nworld']);
165
+ });
166
+
167
+ it('should throw error for shell operators', () => {
168
+ expect(() => parseCommand('echo hello | grep world')).toThrow(/Unsupported shell syntax/);
169
+ expect(() => parseCommand('ls && pwd')).toThrow(/Unsupported shell syntax/);
170
+ expect(() => parseCommand('cat file > output.txt')).toThrow(/Unsupported shell syntax/);
171
+ });
172
+
173
+ it('should throw error for empty command', () => {
174
+ expect(() => parseCommand('')).toThrow(/Command cannot be empty/);
175
+ expect(() => parseCommand(' ')).toThrow(/Command cannot be empty/);
176
+ });
177
+
178
+ it('should handle environment variables as literal references', () => {
179
+ // shell-quote parses $HOME as an env token, which we format as literal string
180
+ // This preserves the variable reference for the job runtime to handle
181
+ const result = parseCommand('echo $HOME');
182
+ expect(result.command).toEqual(['echo', '$HOME']);
183
+ });
184
+
185
+ it('should preserve complex environment variable syntax', () => {
186
+ const result = parseCommand('echo ${FOO:-bar} ${BAR?missing}');
187
+ expect(result.command).toEqual(['echo', '${FOO:-bar}', '${BAR?missing}']);
188
+ });
189
+
190
+ it('should retain special parameter references', () => {
191
+ const result = parseCommand('echo $$ $1 $@ $* $- $! $_');
192
+ expect(result.command).toEqual(['echo', '$$', '$1', '$@', '$*', '$-', '$!', '$_']);
193
+ });
194
+
195
+ it('should treat escaped dollars as literal variables', () => {
196
+ const result = parseCommand('echo \\$FOO "$BAR"');
197
+ expect(result.command).toEqual(['echo', '$FOO', '$BAR']);
198
+ });
199
+
200
+ it('should handle multiline Python in array format', () => {
201
+ const result = parseCommand(['python', '-c', 'import sys\nprint("hello")\nprint("world")']);
202
+ expect(result.command).toEqual(['python', '-c', 'import sys\nprint("hello")\nprint("world")']);
203
+ });
204
+ });
205
+
206
+ describe('createJobSpec', () => {
207
+ it('should create a basic job spec', () => {
208
+ const spec = createJobSpec({
209
+ image: 'python:3.12',
210
+ command: 'python -c "print(123)"',
211
+ });
212
+
213
+ expect(spec.dockerImage).toBe('python:3.12');
214
+ expect(spec.command).toEqual(['python', '-c', 'print(123)']);
215
+ expect(spec.flavor).toBe('cpu-basic');
216
+ });
217
+
218
+ it('should handle Space URLs', () => {
219
+ const spec = createJobSpec({
220
+ image: 'hf.co/spaces/user/app',
221
+ command: 'python run.py',
222
+ });
223
+
224
+ expect(spec.spaceId).toBe('user/app');
225
+ expect(spec.dockerImage).toBeUndefined();
226
+ });
227
+
228
+ it('should include environment variables', () => {
229
+ const spec = createJobSpec({
230
+ image: 'python:3.12',
231
+ command: 'python script.py',
232
+ env: { FOO: 'bar', BAZ: 'qux' },
233
+ });
234
+
235
+ expect(spec.environment).toEqual({ FOO: 'bar', BAZ: 'qux' });
236
+ });
237
+
238
+ it('should include secrets', () => {
239
+ const spec = createJobSpec({
240
+ image: 'python:3.12',
241
+ command: 'python script.py',
242
+ secrets: { API_KEY: 'secret123' },
243
+ });
244
+
245
+ expect(spec.secrets).toEqual({ API_KEY: 'secret123' });
246
+ });
247
+
248
+ it('should keep HF_TOKEN literal when no expansion token provided', () => {
249
+ const spec = createJobSpec({
250
+ image: 'python:3.12',
251
+ command: 'echo $HF_TOKEN',
252
+ });
253
+
254
+ expect(spec.command).toEqual(['echo', '$HF_TOKEN']);
255
+ });
256
+
257
+ it('should keep command literal even when hfToken provided', () => {
258
+ const spec = createJobSpec({
259
+ image: 'python:3.12',
260
+ command: 'echo $HF_TOKEN',
261
+ hfToken: 'hf_secret_999',
262
+ });
263
+
264
+ expect(spec.command).toEqual(['echo', '$HF_TOKEN']);
265
+ });
266
+
267
+ it('should inject HF_TOKEN into secrets when placeholder provided', () => {
268
+ const spec = createJobSpec({
269
+ image: 'python:3.12',
270
+ command: 'python script.py',
271
+ secrets: { HF_TOKEN: '$HF_TOKEN', OTHER: 'keep' },
272
+ hfToken: 'hf_secret_123',
273
+ });
274
+
275
+ expect(spec.secrets).toEqual({ HF_TOKEN: 'hf_secret_123', OTHER: 'keep' });
276
+ });
277
+
278
+ it('should inject HF_TOKEN into env when placeholder provided', () => {
279
+ const spec = createJobSpec({
280
+ image: 'python:3.12',
281
+ command: 'python script.py',
282
+ env: { HF_TOKEN: '${HF_TOKEN}', NAME: 'demo' },
283
+ hfToken: 'hf_env_456',
284
+ });
285
+
286
+ expect(spec.environment).toEqual({ HF_TOKEN: 'hf_env_456', NAME: 'demo' });
287
+ });
288
+
289
+ it('should leave other env values unchanged', () => {
290
+ const spec = createJobSpec({
291
+ image: 'python:3.12',
292
+ command: 'python script.py',
293
+ env: { NAME: 'demo' },
294
+ secrets: { API_KEY: 'secret123' },
295
+ hfToken: 'hf_env_456',
296
+ });
297
+
298
+ expect(spec.environment).toEqual({ NAME: 'demo' });
299
+ expect(spec.secrets).toEqual({ API_KEY: 'secret123' });
300
+ });
301
+
302
+ it('should parse and include timeout', () => {
303
+ const spec = createJobSpec({
304
+ image: 'python:3.12',
305
+ command: 'python script.py',
306
+ timeout: '5m',
307
+ });
308
+
309
+ expect(spec.timeoutSeconds).toBe(300);
310
+ });
311
+
312
+ it('should use specified flavor', () => {
313
+ const spec = createJobSpec({
314
+ image: 'python:3.12',
315
+ command: 'python script.py',
316
+ flavor: 'a10g-small',
317
+ });
318
+
319
+ expect(spec.flavor).toBe('a10g-small');
320
+ });
321
+
322
+ it('should handle array commands', () => {
323
+ const spec = createJobSpec({
324
+ image: 'ubuntu',
325
+ command: ['bash', '-c', 'echo hello'],
326
+ });
327
+
328
+ expect(spec.command).toEqual(['bash', '-c', 'echo hello']);
329
+ });
330
+ });
331
+ });