Skip to content
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# RELEASE NOTES

## 2.0.4 (Jun 9, 2026)

### Enhancements

* Migrated to Go `1.25`.
* Updated vulnerable dependencies.

### Fixes

* Fixed issue that prevented installation of third-party CLI packages ([I#205](https://github.com/akamai/cli/issues/205)).
* Fixed issue that prevented installation of `cps` and `cloudlets` modules with Python `3.14+` ([I#214](https://github.com/akamai/cli/issues/214)).

## 2.0.3 (Dec 10, 2025)

### Enhancements
Expand Down
4 changes: 2 additions & 2 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,10 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright {yyyy} {name of copyright owner}
Copyright 2026 Akamai Technologies, Inc. All rights reserved.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
you may not use these files except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0
Expand Down
339 changes: 159 additions & 180 deletions README.md

Large diffs are not rendered by default.

14 changes: 0 additions & 14 deletions cli/app/access_nix.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,5 @@
//go:build !windows

// Copyright 2018. Akamai Technologies, 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 app

import "golang.org/x/sys/unix"
Expand Down
14 changes: 0 additions & 14 deletions cli/app/access_windows.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,3 @@
// Copyright 2018. Akamai Technologies, 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 app

func checkWriteAccess(path string) error {
Expand Down
14 changes: 0 additions & 14 deletions cli/app/firstrun.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,5 @@
//go:build !nofirstrun

// Copyright 2018. Akamai Technologies, 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 app

import (
Expand Down
14 changes: 0 additions & 14 deletions cli/app/firstrun_noinstall.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,5 @@
//go:build nofirstrun

// Copyright 2018. Akamai Technologies, 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 app

import (
Expand Down
14 changes: 0 additions & 14 deletions pkg/apphelp/help_command.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,3 @@
// Copyright 2018. Akamai Technologies, 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 apphelp

import (
Expand Down
14 changes: 0 additions & 14 deletions pkg/commands/command.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,3 @@
// Copyright 2018. Akamai Technologies, 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 commands

import (
Expand Down
14 changes: 0 additions & 14 deletions pkg/commands/command_config.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,3 @@
// Copyright 2018. Akamai Technologies, 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 commands

import (
Expand Down
146 changes: 112 additions & 34 deletions pkg/commands/command_install.go
Original file line number Diff line number Diff line change
@@ -1,24 +1,11 @@
// Copyright 2018. Akamai Technologies, 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 commands

import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
Expand All @@ -37,9 +24,12 @@ import (

var (
thirdPartyDisclaimer = color.CyanString("Disclaimer: You are installing a third-party package, subject to its own terms and conditions. Akamai makes no warranty or representation with respect to the third-party package.")
githubRawURLTemplate = "https://raw.githubusercontent.com/akamai/%s/master/cli.json"
)

var buildRawGitHubURL = func(owner, repo, branch string) string {
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/cli.json", owner, repo, branch)
}

func cmdInstall(git git.Repository, langManager packages.LangManager) cli.ActionFunc {
return func(c *cli.Context) (e error) {
start := time.Now()
Expand All @@ -65,7 +55,16 @@ func cmdInstall(git git.Repository, langManager packages.LangManager) cli.Action
oldCmds := getCommands(c)

for _, repo := range c.Args().Slice() {
repo = tools.Githubize(repo)
repo = strings.TrimSpace(repo)

if repo == "" {
return cli.Exit(color.RedString("Repository URL cannot be empty"), 1)
}

if !strings.Contains(repo, "://") && !strings.HasPrefix(repo, "git@") {
repo = tools.Githubize(repo)
}

subCmd, err := installPackage(c.Context, git, langManager, repo)
if err != nil {
logger.Error(fmt.Sprintf("Error installing package: %v", err))
Expand Down Expand Up @@ -132,16 +131,29 @@ func installPackage(ctx context.Context, gitRepo git.Repository, langManager pac
logger := log.FromContext(ctx)
logger.Debug(fmt.Sprintf("Installing package from repository: %s", repo))

repo = strings.TrimSpace(repo)
if repo == "" {
userMessage := "The provided repository URL is empty or contains only whitespace. " +
"Please specify a valid repository URL in a supported format " +
"(e.g., 'https://github.com/<owner>/<repo>.git' or 'git@github.com:<owner>/<repo>')."
logger.Error(userMessage)
return nil, cli.Exit(color.RedString("%s", userMessage), 1)
}

srcPath, err := tools.GetAkamaiCliSrcPath()
if err != nil {
logger.Error(fmt.Sprintf("Unable to get akamai cli source path: %v", err))
return nil, err
}

term := terminal.Get(ctx)
spin := term.Spinner()
owner, repoName := extractOwnerAndRepo(repo)
if owner == "" || repoName == "" {
msg := fmt.Sprintf("Unable to parse repository URL: %s", repo)
logger.Error(msg)
return nil, cli.Exit(color.RedString("Unable to install selected package"), 1)
}

dirName := strings.TrimSuffix(filepath.Base(repo), ".git")
dirName := repoName
packageDir := filepath.Join(srcPath, dirName)

if _, err = os.Stat(packageDir); err == nil {
Expand All @@ -150,40 +162,70 @@ func installPackage(ctx context.Context, gitRepo git.Repository, langManager pac
return nil, cli.Exit(color.YellowString("%s", warningMsg), 0)
}

term := terminal.Get(ctx)
spin := term.Spinner()

spin.Start("Attempting to fetch package configuration from %s...", repo)

base := filepath.Base(dirName)
url := fmt.Sprintf(githubRawURLTemplate, base)
cmdPackage, err := readPackageFromGithub(url, dirName)
if err != nil {
spin.Stop(terminal.SpinnerStatusFail)
logger.Error(fmt.Sprintf("Failed to read package from github: %v", err))
term.WriteError(err.Error())
isOfficial := owner == "akamai" && strings.HasPrefix(repoName, "cli-")

var cmdPackage subcommands

if strings.Contains(err.Error(), "404") {
return nil, cli.Exit(color.RedString("%s", tools.CapitalizeFirstWord(git.ErrPackageNotAvailable.Error())), 1)
if strings.HasPrefix(repo, "https://github.com/") {
var fetchErr error
for _, branch := range []string{"main", "master"} {
url := buildRawGitHubURL(owner, repoName, branch)
cmdPackage, fetchErr = readPackageFromGithub(url, dirName)
if fetchErr == nil {
break
}
}
return nil, cli.Exit(color.RedString("%s", "Unable to install selected package"), 1)
if fetchErr != nil {
spin.Stop(terminal.SpinnerStatusFail)
logger.Error(fmt.Sprintf("Failed to read package from github: %v", fetchErr))
term.WriteError(fetchErr.Error())

if strings.Contains(fetchErr.Error(), "404") {
userMessage := fmt.Sprintf(`%s

Reason:
- The repository either does not exist, is private, or does not contain a 'cli.json' file.

Steps to resolve this issue:
1. Verify that the URL is correct.
2. Ensure the repository is publicly accessible, or that you have the necessary permissions.
3. Confirm that the repository contains the required 'cli.json' configuration file.

For a list of supported packages, visit:
%s
`, repo, "https://techdocs.akamai.com/home/page/products-tools-a-z")

logger.Error(strings.TrimSpace(userMessage))
term.WriteError(strings.TrimSpace(userMessage))

return nil, cli.Exit(color.RedString("%s", tools.CapitalizeFirstWord(git.ErrPackageNotAvailable.Error())), 1)
}
return nil, cli.Exit(color.RedString("Unable to install selected package"), 1)
}
spin.OK()
}
spin.OK()

if isBinary(cmdPackage) {
if strings.HasPrefix(repo, "https://github.com/") && isBinary(cmdPackage) {
logger.Debug(fmt.Sprintf("Installing binaries for package in directory: %s", packageDir))
ok, subCmd := installPackageBinaries(ctx, packageDir, cmdPackage, logger)
if ok {
return subCmd, nil
}
// delete package directory
if err := os.RemoveAll(packageDir); err != nil {
logger.Error(fmt.Sprintf("Failed to remove package directory: %v", err))
return nil, err
}
logger.Debug(fmt.Sprintf("Unable to install binaries for package in directory: %s, cloning repository: %s", packageDir, repo))
logger.Debug(fmt.Sprintf("Unable to install binaries, cloning repository: %s", repo))
}

spin.Start("Attempting to fetch command from %s...", repo)

if !strings.HasPrefix(repo, "https://github.com/akamai/cli-") && !strings.HasPrefix(repo, "git@github.com:akamai/cli-") {
if !isOfficial {
term.Printf(color.CyanString("%s", thirdPartyDisclaimer))
}

Expand Down Expand Up @@ -307,3 +349,39 @@ func installPackageBinaries(ctx context.Context, dir string, cmdPackage subcomma

return true, &cmdPackage
}

// extractOwnerAndRepo parses a GitHub repo URL and returns the owner and repo name.
func extractOwnerAndRepo(repoURL string) (string, string) {
repoURL = strings.TrimSuffix(repoURL, ".git")

// Handle SCP-style SSH: git@github.com:owner/repo
if strings.HasPrefix(repoURL, "git@") {
if i := strings.Index(repoURL, ":"); i != -1 {
path := repoURL[i+1:]
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) >= 2 {
return parts[0], parts[1]
}
}
return "", ""
}

// Handle URL schemes (ssh://, https://, http://)
if u, err := url.Parse(repoURL); err == nil && u.Path != "" {
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) >= 2 {
return parts[len(parts)-2], parts[len(parts)-1]
}
}

// Handle local file URLs
if strings.HasPrefix(repoURL, "file://") {
localPath := strings.TrimPrefix(repoURL, "file://")
repoName := filepath.Base(localPath)
if repoName != "" {
return "local", repoName
}
}

return "", ""
}
Loading
Loading