Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ cmd/lk/testdata/**/package-lock.json

# generated at release time by release.yaml
autocomplete/fish_autocomplete

# Python bytecode from the assistant agent
__pycache__/
10 changes: 9 additions & 1 deletion cmd/lk/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ On LiveKit Cloud: "create" and "deploy" ship it, then "status", "logs",
{
Name: "init",
Usage: "Create a new agent project from a template",
Description: `Creates the project in a new directory named AGENT-NAME. To create it in
the current directory instead, pass "." as AGENT-NAME. The directory must be
empty, and the agent is named after it.`,
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
return createAgentClientWithOpts(ctx, cmd, confirmProject)
},
Expand Down Expand Up @@ -589,7 +592,12 @@ func initAgent(ctx context.Context, cmd *cli.Command) error {

logger.Debugw("Initializing agent project", "working-dir", workingDir)

appName = cmd.Args().First()
if arg := cmd.Args().First(); arg != "" {
var err error
if appName, appDir, err = resolveAppTarget(arg); err != nil {
return err
}
}
if appName == "" {
appName = project.Name
}
Expand Down
75 changes: 57 additions & 18 deletions cmd/lk/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"io"
"maps"
"os"
"path/filepath"
"regexp"
"strings"

Expand All @@ -44,6 +45,7 @@ var (
templateURL string
sandboxID string
appName string
appDir string // where the app is created; appName unless "." was given
appNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]*$`)
destinationFile string
exampleFile string
Expand All @@ -55,8 +57,11 @@ var (
Usage: "Initialize and manage applications",
Commands: []*cli.Command{
{
Name: "create",
Usage: "Bootstrap a new application from a template or through guided creation",
Name: "create",
Usage: "Bootstrap a new application from a template or through guided creation",
Description: `Creates the application in a new directory named APP_NAME. To create it in
the current directory instead, pass "." as APP_NAME. The directory must be
empty, and the application is named after it.`,
Action: setupTemplate,
Before: requireProject,
ArgsUsage: "`APP_NAME`",
Expand Down Expand Up @@ -356,9 +361,11 @@ func setupTemplateWith(ctx context.Context, cmd *cli.Command, afterInstall func(
}

if appName == "" {
arg := cmd.Args().First()
if arg != "" {
appName = arg
if arg := cmd.Args().First(); arg != "" {
var err error
if appName, appDir, err = resolveAppTarget(arg); err != nil {
return err
}
}
}
if appName == "" {
Expand Down Expand Up @@ -396,16 +403,20 @@ func setupTemplateWith(ctx context.Context, cmd *cli.Command, afterInstall func(
}
}

if appDir == "" {
appDir = appName
}

// Set environment variables for template instantiation
os.Setenv("LIVEKIT_AGENT_NAME", appName)
os.Setenv("LIVEKIT_PROJECT_ID", project.ProjectId)

out.Status("Cloning template...")
if err := cloneTemplate(ctx, cmd, templateURL, appName); err != nil {
if err := cloneTemplate(ctx, cmd, templateURL, appDir); err != nil {
return err
}

tf, err := bootstrap.ParseTaskfile(appName)
tf, err := bootstrap.ParseTaskfile(appDir)
if err != nil {
return err
}
Expand All @@ -429,12 +440,12 @@ func setupTemplateWith(ctx context.Context, cmd *cli.Command, afterInstall func(
}
}
}
env, err := instantiateEnv(ctx, cmd, appName, addlEnv, envExampleFile, nil)
env, err := instantiateEnv(ctx, cmd, appDir, addlEnv, envExampleFile, nil)
if err != nil {
return err
}

bootstrap.WriteDotEnv(appName, envOutputFile, env, true)
bootstrap.WriteDotEnv(appDir, envOutputFile, env, true)

if !cmd.IsSet("install") && !SkipPrompts(cmd) {
// Default the prompt to "yes" — installing deps is the common case.
Expand All @@ -449,7 +460,7 @@ func setupTemplateWith(ctx context.Context, cmd *cli.Command, afterInstall func(
}
if install {
out.Status("Installing template...")
if err := doInstall(ctx, bootstrap.TaskInstall, appName, verbose); err != nil {
if err := doInstall(ctx, bootstrap.TaskInstall, appDir, verbose); err != nil {
// Installation is best-effort — the agent is still created below. But a
// failed install (e.g. missing Node/pnpm) is easy to miss once the
// template's post-create step prints "agent created", so render a
Expand All @@ -467,31 +478,59 @@ func setupTemplateWith(ctx context.Context, cmd *cli.Command, afterInstall func(
b.WriteString(line)
b.WriteString("\n")
}
out.Warnf("%s%sFix your toolchain, then re-run the install step manually in ./%s.", b.String(), fixPrefix, appName)
out.Warnf("%s%sFix your toolchain, then re-run the install step manually in ./%s.", b.String(), fixPrefix, appDir)
} else {
// Signal a successful install to post_create so the template can skip
// printing the now-redundant install hint (guarded via `status:`).
os.Setenv("LIVEKIT_DEPS_INSTALLED", "1")
}
}
if afterInstall != nil {
if err := afterInstall(ctx, cmd, appName); err != nil {
if err := afterInstall(ctx, cmd, appDir); err != nil {
return err
}
}
if err := doPostCreate(ctx, cmd, appName, verbose); err != nil {
if err := doPostCreate(ctx, cmd, appDir, verbose); err != nil {
return err
}

return cleanupTemplate(ctx, cmd, appName)
return cleanupTemplate(ctx, cmd, appDir)
}

// resolveAppTarget returns the app's name and the directory to create it in.
// "." creates the app in the current directory, which must be empty, and
// names it after the directory.
func resolveAppTarget(arg string) (name, dir string, err error) {
if arg != "." {
return arg, arg, nil
}
wd, err := os.Getwd()
if err != nil {
return "", "", err
}
if empty, err := util.IsEmptyDir("."); err != nil {
return "", "", err
} else if !empty {
return "", "", errors.New("the current directory isn't empty; to create the app here, run this in an empty directory, or give a name to create a new directory")
}
name = filepath.Base(wd)
if !appNameRegex.MatchString(name) {
return "", "", fmt.Errorf("the current directory's name, %q, can't be used as the app name; use letters, numbers, dashes, and underscores", name)
}
return name, ".", nil
}

func cloneTemplate(ctx context.Context, cmd *cli.Command, url, appName string) error {
// cloneTemplate clones the template at url into dir. dir must not exist,
// except "." (the current directory), which must be empty.
func cloneTemplate(ctx context.Context, cmd *cli.Command, url, dir string) error {
var stdout string
var stderr string

tempName, relocate, cleanup := util.UseTempPath(appName)
tempName, relocate, cleanup := util.UseTempPath(dir)
defer cleanup()
if dir == "." {
relocate = func() error { return util.MoveDirInto(tempName, dir) }
}

err := out.Await(
"Cloning template from "+url,
Expand Down Expand Up @@ -522,8 +561,8 @@ func cloneTemplate(ctx context.Context, cmd *cli.Command, url, appName string) e
return relocate()
}

func cleanupTemplate(ctx context.Context, cmd *cli.Command, appName string) error {
return bootstrap.CleanupTemplate(appName)
func cleanupTemplate(ctx context.Context, cmd *cli.Command, dir string) error {
return bootstrap.CleanupTemplate(dir)
}

func manageEnv(ctx context.Context, cmd *cli.Command) error {
Expand Down
55 changes: 55 additions & 0 deletions cmd/lk/app_target_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

func TestResolveAppTargetNamed(t *testing.T) {
name, dir, err := resolveAppTarget("my-agent")
require.NoError(t, err)
require.Equal(t, "my-agent", name)
require.Equal(t, "my-agent", dir)
}

func TestResolveAppTargetCurrentDir(t *testing.T) {
root := t.TempDir()

empty := filepath.Join(root, "voice-app")
require.NoError(t, os.Mkdir(empty, 0o755))
t.Chdir(empty)
name, dir, err := resolveAppTarget(".")
require.NoError(t, err)
require.Equal(t, "voice-app", name)
require.Equal(t, ".", dir)

busy := filepath.Join(root, "busy")
require.NoError(t, os.Mkdir(busy, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(busy, "notes.txt"), nil, 0o644))
t.Chdir(busy)
_, _, err = resolveAppTarget(".")
require.ErrorContains(t, err, "isn't empty")

badName := filepath.Join(root, "has space")
require.NoError(t, os.Mkdir(badName, 0o755))
t.Chdir(badName)
_, _, err = resolveAppTarget(".")
require.ErrorContains(t, err, "can't be used as the app name")
}
Loading
Loading