Skip to content

Commit 994b35e

Browse files
committed
feat(agents): Add command: lk agent generate
- Adds the "lk agent generate <type> [--silent] [--force]" command - Types available: - dockerfile: generates just a dockerfile - dockerignore: generates just a dockerignore - dockerfiles: generates both - Will not overwrite existing files unless you provide the "--force" flag - "--silent" makes it play nice with scripts by making automatic choices and suppressing the terminal spam
1 parent e5e34ed commit 994b35e

File tree

2 files changed

+283
-24
lines changed

2 files changed

+283
-24
lines changed

cmd/lk/agent.go

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,30 @@ var (
260260
DisableSliceFlagSeparator: true,
261261
ArgsUsage: "[working-dir]",
262262
},
263+
{
264+
Name: "generate",
265+
Usage: "Generate Dockerfiles for agent deployment",
266+
Action: generateDockerfiles,
267+
ArgsUsage: "<type> [working-dir]",
268+
Flags: []cli.Flag{
269+
&cli.BoolFlag{
270+
Name: "force",
271+
Usage: "Overwrite existing files",
272+
},
273+
silentFlag,
274+
},
275+
Description: `Generate Docker-related files for agent deployment.
276+
277+
Types:
278+
dockerfiles - Generate both Dockerfile and .dockerignore
279+
dockerfile - Generate only Dockerfile
280+
dockerignore - Generate only .dockerignore
281+
282+
Examples:
283+
lk agent generate dockerfiles # Generate both files
284+
lk agent generate dockerfile --force # Overwrite existing Dockerfile
285+
lk agent generate dockerignore . # Generate .dockerignore in current dir`,
286+
},
263287
},
264288
},
265289
}
@@ -1145,9 +1169,34 @@ func requireDockerfile(_ context.Context, cmd *cli.Command, workingDir string, s
11451169
}
11461170

11471171
if !dockerfileExists {
1148-
if err := agentfs.CreateDockerfile(workingDir, settingsMap, cmd.Bool("silent")); err != nil {
1172+
silent := cmd.Bool("silent")
1173+
1174+
// Prepare content once for both files
1175+
dockerfileContent, dockerignoreContent, err := agentfs.PrepareDockerfileContent(workingDir, settingsMap, silent)
1176+
if err != nil {
11491177
return err
11501178
}
1179+
1180+
// Generate both files (force=false to respect any existing files)
1181+
dockerfileResult := agentfs.GenerateDockerfile(workingDir, settingsMap, silent, false, dockerfileContent)
1182+
if dockerfileResult.Status == agentfs.GenerationStatusFailed {
1183+
return dockerfileResult.Error
1184+
}
1185+
1186+
dockerignoreResult := agentfs.GenerateDockerIgnore(workingDir, settingsMap, silent, false, dockerignoreContent)
1187+
if dockerignoreResult.Status == agentfs.GenerationStatusFailed {
1188+
return dockerignoreResult.Error
1189+
}
1190+
1191+
// Show success message if not silent
1192+
if !silent {
1193+
fmt.Printf("\n✔ Successfully generated Docker files:\n")
1194+
fmt.Printf(" %s - Container build instructions\n", util.Accented("Dockerfile"))
1195+
fmt.Printf(" %s - Files excluded from build context\n", util.Accented(".dockerignore"))
1196+
fmt.Printf("\nNext steps:\n")
1197+
fmt.Printf(" ► Review the %s and uncomment/update any needed packages\n", util.Accented("Dockerfile"))
1198+
fmt.Printf(" ► Build your agent: docker build -t my-agent .\n")
1199+
}
11511200
} else {
11521201
if !cmd.Bool("silent") {
11531202
fmt.Println("Using existing Dockerfile")
@@ -1199,3 +1248,108 @@ func requireConfig(workingDir, tomlFilename string) (bool, error) {
11991248
lkConfig, exists, err = config.LoadTOMLFile(workingDir, tomlFilename)
12001249
return exists, err
12011250
}
1251+
1252+
func generateDockerfiles(ctx context.Context, cmd *cli.Command) error {
1253+
if cmd.NArg() < 1 {
1254+
return fmt.Errorf("type argument required: dockerfiles, dockerfile, or dockerignore")
1255+
}
1256+
1257+
generateType := cmd.Args().First()
1258+
if cmd.NArg() > 1 {
1259+
workingDir = cmd.Args().Get(1)
1260+
}
1261+
1262+
// Validate type
1263+
validTypes := []string{"dockerfiles", "dockerfile", "dockerignore"}
1264+
if !slices.Contains(validTypes, generateType) {
1265+
return fmt.Errorf("invalid type: %s. Must be one of: %s", generateType, strings.Join(validTypes, ", "))
1266+
}
1267+
1268+
silent := cmd.Bool("silent")
1269+
force := cmd.Bool("force")
1270+
1271+
// Try to get client settings, but don't fail if unavailable
1272+
settingsMap := make(map[string]string)
1273+
if project != nil && agentsClient != nil {
1274+
settingsMap, _ = getClientSettings(ctx, silent)
1275+
}
1276+
// Provide defaults if no settings available, generating docker files shouldn't fail due to client settings
1277+
if len(settingsMap) == 0 {
1278+
settingsMap["python_entrypoint"] = "main.py"
1279+
settingsMap["node_entrypoint"] = "dist/agent.js"
1280+
}
1281+
1282+
// Track results
1283+
var dockerfileResult, dockerignoreResult agentfs.GenerationResult
1284+
generateDockerfile := generateType == "dockerfiles" || generateType == "dockerfile"
1285+
generateDockerignore := generateType == "dockerfiles" || generateType == "dockerignore"
1286+
1287+
// Prepare the docker files contents once to avoid prompting user twice
1288+
preparedDockerfileContent, preparedDockerignoreContent, err := agentfs.PrepareDockerfileContent(workingDir, settingsMap, silent)
1289+
if err != nil {
1290+
// Both generations will fail with the same error
1291+
dockerfileResult = agentfs.GenerationResult{
1292+
Status: agentfs.GenerationStatusFailed,
1293+
Error: err,
1294+
Message: fmt.Sprintf("Failed to prepare Docker files: %v", err),
1295+
}
1296+
dockerignoreResult = dockerfileResult
1297+
}
1298+
1299+
// Generate Dockerfile if requested
1300+
if generateDockerfile {
1301+
if dockerfileResult.Status != agentfs.GenerationStatusFailed {
1302+
dockerfileResult = agentfs.GenerateDockerfile(workingDir, settingsMap, silent, force, preparedDockerfileContent)
1303+
}
1304+
if !silent && dockerfileResult.Status != agentfs.GenerationStatusGenerated {
1305+
fmt.Printf("! %s\n", dockerfileResult.Message)
1306+
} else if !silent && dockerfileResult.Status == agentfs.GenerationStatusGenerated {
1307+
fmt.Printf("✔ %s\n", dockerfileResult.Message)
1308+
}
1309+
}
1310+
1311+
// Generate .dockerignore if requested
1312+
if generateDockerignore {
1313+
if dockerignoreResult.Status != agentfs.GenerationStatusFailed {
1314+
dockerignoreResult = agentfs.GenerateDockerIgnore(workingDir, settingsMap, silent, force, preparedDockerignoreContent)
1315+
}
1316+
if !silent && dockerignoreResult.Status != agentfs.GenerationStatusGenerated {
1317+
fmt.Printf("! %s\n", dockerignoreResult.Message)
1318+
} else if !silent && dockerignoreResult.Status == agentfs.GenerationStatusGenerated {
1319+
fmt.Printf("✔ %s\n", dockerignoreResult.Message)
1320+
}
1321+
}
1322+
1323+
// Summary output for dockerfiles generation
1324+
if !silent && generateType == "dockerfiles" {
1325+
fmt.Println()
1326+
1327+
bothGenerated := dockerfileResult.Status == agentfs.GenerationStatusGenerated &&
1328+
dockerignoreResult.Status == agentfs.GenerationStatusGenerated
1329+
bothSkipped := dockerfileResult.Status == agentfs.GenerationStatusSkipped &&
1330+
dockerignoreResult.Status == agentfs.GenerationStatusSkipped
1331+
1332+
if bothGenerated {
1333+
fmt.Println("✔ Successfully generated both Docker files")
1334+
fmt.Printf("\nNext steps:\n")
1335+
fmt.Printf(" ► Review the %s and uncomment/update any needed packages\n", util.Accented("Dockerfile"))
1336+
fmt.Printf(" ► Build your agent: docker build -t my-agent .\n")
1337+
} else if bothSkipped {
1338+
fmt.Println("! Both files already exist. Use --force to overwrite")
1339+
} else if dockerfileResult.Status == agentfs.GenerationStatusGenerated {
1340+
fmt.Println("✔ Generated Dockerfile (.dockerignore already exists)")
1341+
} else if dockerignoreResult.Status == agentfs.GenerationStatusGenerated {
1342+
fmt.Println("✔ Generated .dockerignore (Dockerfile already exists)")
1343+
}
1344+
}
1345+
1346+
// Return first error if any failures occurred
1347+
if dockerfileResult.Status == agentfs.GenerationStatusFailed {
1348+
return dockerfileResult.Error
1349+
}
1350+
if dockerignoreResult.Status == agentfs.GenerationStatusFailed {
1351+
return dockerignoreResult.Error
1352+
}
1353+
1354+
return nil
1355+
}

pkg/agentfs/docker.go

Lines changed: 128 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ import (
3434
//go:embed examples/*
3535
var fs embed.FS
3636

37+
type GenerationStatus int
38+
39+
const (
40+
GenerationStatusGenerated GenerationStatus = iota
41+
GenerationStatusSkipped // File exists and no force flag
42+
GenerationStatusFailed // Error during generation
43+
)
44+
45+
type GenerationResult struct {
46+
Status GenerationStatus
47+
Message string
48+
Error error
49+
}
50+
3751
func HasDockerfile(dir string) (bool, error) {
3852
entries, err := os.ReadDir(dir)
3953
if err != nil {
@@ -48,20 +62,22 @@ func HasDockerfile(dir string) (bool, error) {
4862
return false, nil
4963
}
5064

51-
func CreateDockerfile(dir string, settingsMap map[string]string, silent bool) error {
65+
// Returns dockerfile content, dockerignore content, and error
66+
func PrepareDockerfileContent(dir string, settingsMap map[string]string, silent bool) ([]byte, []byte, error) {
5267
if len(settingsMap) == 0 {
53-
return fmt.Errorf("unable to fetch client settings from server, please try again later")
68+
return nil, nil, fmt.Errorf("unable to fetch client settings from server, please try again later")
5469
}
5570

5671
projectType, err := DetectProjectType(dir)
5772
if err != nil {
58-
return fmt.Errorf(`× Unable to determine project type
73+
return nil, nil, fmt.Errorf(`× Unable to determine project type
5974
6075
Supported project types:
61-
• Python: requires requirements.txt or pyproject.toml
62-
• Node.js: requires package.json
76+
• Python: pip, uv, pdm, hatch, poetry, pipenv
77+
• Node.js: npm, pnpm, yarn, yarn-berry, bun
6378
64-
Please ensure your project has the appropriate dependency file, or create a Dockerfile manually in the current directory`)
79+
Please ensure your project has the appropriate project files (node projects may
80+
need to be buit first), or create a Dockerfile manually in the current directory`)
6581
}
6682

6783
if !silent {
@@ -142,47 +158,136 @@ Please ensure your project has the appropriate dependency file, or create a Dock
142158

143159
dockerfileContent, err = fs.ReadFile("examples/" + string(projectType) + ".Dockerfile")
144160
if err != nil {
145-
return fmt.Errorf("failed to load Dockerfile template '%s': %w", string(projectType), err)
161+
return nil, nil, fmt.Errorf("failed to load Dockerfile template '%s': %w", string(projectType), err)
146162
}
147163

148164
dockerIgnoreContent, err = fs.ReadFile("examples/" + string(projectType) + ".dockerignore")
149165
if err != nil {
150-
return fmt.Errorf("failed to load .dockerignore template for '%s': %w", string(projectType), err)
166+
return nil, nil, fmt.Errorf("failed to load .dockerignore template for '%s': %w", string(projectType), err)
151167
}
152168

153169
// Validate entrypoint for both Python and Node.js projects
154170
if projectType.IsPython() {
155171
dockerfileContent, err = validateEntrypoint(dir, dockerfileContent, dockerIgnoreContent, projectType, settingsMap, silent)
156172
if err != nil {
157-
return fmt.Errorf("failed to validate Python entry point: %w", err)
173+
return nil, nil, fmt.Errorf("failed to validate Python entry point: %w", err)
158174
}
159175
} else if projectType.IsNode() {
160176
dockerfileContent, err = validateEntrypoint(dir, dockerfileContent, dockerIgnoreContent, projectType, settingsMap, silent)
161177
if err != nil {
162-
return fmt.Errorf("failed to validate Node.js entry point: %w", err)
178+
return nil, nil, fmt.Errorf("failed to validate Node.js entry point: %w", err)
163179
}
164180
}
165181

166-
err = os.WriteFile(filepath.Join(dir, "Dockerfile"), dockerfileContent, 0644)
182+
return dockerfileContent, dockerIgnoreContent, nil
183+
}
184+
185+
// GenerateDockerfile generates only the Dockerfile with status reporting
186+
// If preparedDockerfileContent is provided (non-nil), it will be used instead of calling PrepareDockerfileContent
187+
func GenerateDockerfile(dir string, settingsMap map[string]string, silent bool, force bool, preparedDockerfileContent []byte) GenerationResult {
188+
dockerfilePath := filepath.Join(dir, "Dockerfile")
189+
190+
// Check if file exists
191+
if !force {
192+
if _, err := os.Stat(dockerfilePath); err == nil {
193+
return GenerationResult{
194+
Status: GenerationStatusSkipped,
195+
Message: "Dockerfile already exists. Use --force to overwrite",
196+
}
197+
}
198+
}
199+
200+
// Get content - use prepared content if provided, otherwise generate it
201+
var dockerfileContent []byte
202+
if preparedDockerfileContent != nil {
203+
dockerfileContent = preparedDockerfileContent
204+
} else {
205+
var err error
206+
dockerfileContent, _, err = PrepareDockerfileContent(dir, settingsMap, silent)
207+
if err != nil {
208+
return GenerationResult{
209+
Status: GenerationStatusFailed,
210+
Error: err,
211+
Message: fmt.Sprintf("Failed to prepare Dockerfile: %v", err),
212+
}
213+
}
214+
}
215+
216+
// Write file
217+
err := os.WriteFile(dockerfilePath, dockerfileContent, 0644)
167218
if err != nil {
168-
return fmt.Errorf("failed to write Dockerfile: %w", err)
219+
return GenerationResult{
220+
Status: GenerationStatusFailed,
221+
Error: fmt.Errorf("failed to write Dockerfile: %w", err),
222+
Message: fmt.Sprintf("Failed to write Dockerfile: %v", err),
223+
}
224+
}
225+
226+
message := "Generated Dockerfile"
227+
if force {
228+
if _, err := os.Stat(dockerfilePath); err == nil {
229+
message = "Overwrote existing Dockerfile"
230+
}
231+
}
232+
233+
return GenerationResult{
234+
Status: GenerationStatusGenerated,
235+
Message: message,
236+
}
237+
}
238+
239+
// GenerateDockerIgnore generates only the .dockerignore with status reporting
240+
// If preparedDockerignoreContent is provided (non-nil), it will be used instead of calling PrepareDockerfileContent
241+
func GenerateDockerIgnore(dir string, settingsMap map[string]string, silent bool, force bool, preparedDockerignoreContent []byte) GenerationResult {
242+
dockerignorePath := filepath.Join(dir, ".dockerignore")
243+
244+
// Check if file exists
245+
if !force {
246+
if _, err := os.Stat(dockerignorePath); err == nil {
247+
return GenerationResult{
248+
Status: GenerationStatusSkipped,
249+
Message: ".dockerignore already exists. Use --force to overwrite",
250+
}
251+
}
169252
}
170253

171-
err = os.WriteFile(filepath.Join(dir, ".dockerignore"), dockerIgnoreContent, 0644)
254+
// Get content - use prepared content if provided, otherwise generate it
255+
var dockerignoreContent []byte
256+
if preparedDockerignoreContent != nil {
257+
dockerignoreContent = preparedDockerignoreContent
258+
} else {
259+
var err error
260+
_, dockerignoreContent, err = PrepareDockerfileContent(dir, settingsMap, silent)
261+
if err != nil {
262+
return GenerationResult{
263+
Status: GenerationStatusFailed,
264+
Error: err,
265+
Message: fmt.Sprintf("Failed to prepare .dockerignore: %v", err),
266+
}
267+
}
268+
}
269+
270+
// Write file
271+
err := os.WriteFile(dockerignorePath, dockerignoreContent, 0644)
172272
if err != nil {
173-
return fmt.Errorf("failed to write .dockerignore: %w", err)
273+
return GenerationResult{
274+
Status: GenerationStatusFailed,
275+
Error: fmt.Errorf("failed to write .dockerignore: %w", err),
276+
Message: fmt.Sprintf("Failed to write .dockerignore: %v", err),
277+
}
174278
}
175279

176-
if !silent {
177-
fmt.Printf("\n✔ Successfully generated Docker files:\n")
178-
fmt.Printf(" %s - Container build instructions\n", util.Accented("Dockerfile"))
179-
fmt.Printf(" %s - Files excluded from build context\n", util.Accented(".dockerignore"))
180-
fmt.Printf("\nNext steps:\n")
181-
fmt.Printf(" ► Review the %s and uncomment/update any needed packages\n", util.Accented("Dockerfile"))
182-
fmt.Printf(" ► Build your agent: docker build -t my-agent .\n")
280+
message := "Generated .dockerignore"
281+
if force {
282+
if _, err := os.Stat(dockerignorePath); err == nil {
283+
message = "Overwrote existing .dockerignore"
284+
}
183285
}
184286

185-
return nil
287+
return GenerationResult{
288+
Status: GenerationStatusGenerated,
289+
Message: message,
290+
}
186291
}
187292

188293
func validateUVProject(dir string, silent bool) {
@@ -339,7 +444,7 @@ func validateEntrypoint(dir string, dockerfileContent []byte, dockerignoreConten
339444
allowedPaths["dist"] = true
340445
allowedPaths["out"] = true
341446
}
342-
447+
343448
// TODO: Parse tsconfig.json "outDir" if it exists to get the actual output directory
344449
// For now, we're using common conventions
345450
}

0 commit comments

Comments
 (0)