Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d75bdab
Add release Makefile targets
brandonc Jul 24, 2026
0279959
make gen/openapi
brandonc Jul 24, 2026
051f833
harness exec permissions tightening, expand types
brandonc Jul 24, 2026
a2bc6c3
update harness exec language/docs
brandonc Jul 24, 2026
00bec7c
fix non-banner commands like `--autocomplete-install`
brandonc Jul 24, 2026
c6ff2dd
refactor move banner to own command
brandonc Jul 24, 2026
e2273b7
replace cksum usage with compatible go util
brandonc Jul 24, 2026
b16f785
detect and alert about any outdated skill files
brandonc Jul 24, 2026
3b79662
lint fixes
brandonc Jul 24, 2026
24436b8
version command, outdated skill check
brandonc Jul 26, 2026
5b3b2ea
run version command for no args
brandonc Jul 26, 2026
7aa71ec
fix lint warnings, add versioncmd tests
brandonc Jul 26, 2026
c767b85
secvuln: bump kin-openapi to v0.145.0
brandonc Jul 27, 2026
2170259
refactor: replace crc32 with sha256sum
brandonc Jul 27, 2026
59bb7b8
allow -v and -version for version command
brandonc Jul 27, 2026
6d8ed73
output newline before new version notice
brandonc Jul 27, 2026
efa547c
known hashes of existing skills
brandonc Jul 27, 2026
cf76b8c
feature: just upgrade outdated skills
brandonc Jul 28, 2026
4241949
updates unreleased notes
brandonc Jul 28, 2026
8156499
fix skill migrations
brandonc Jul 29, 2026
8c24e6c
fix: MatchesKnownVersions accepts a resolved path
brandonc Jul 29, 2026
7ad078c
reword and reformat harness exec delete warning
brandonc Jul 29, 2026
b1ea424
adds provider-sets to registry
brandonc Jul 29, 2026
dcecb6e
fix: prepare-release field order
brandonc Jul 29, 2026
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 .changes/unreleased/ENHANCEMENTS-20260724-172011.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: ENHANCEMENTS
body: Running tfctl, tfctl version, or tfctl --version checks whether there is a newer version of tfctl available and whether `auth login` needs to be run.
time: 2026-07-24T17:20:11.173085-06:00
3 changes: 3 additions & 0 deletions .changes/unreleased/ENHANCEMENTS-20260728-150652.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: ENHANCEMENTS
body: tfctl now detects outdated skills it installed and migrates them to the latest version.
time: 2026-07-28T15:06:52.901832-06:00
2 changes: 1 addition & 1 deletion .changes/v0.4.0-beta.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

NEW FEATURES:

* Adds the `harness exec` command, which lets a human grant session-scoped, noninteractive `tfctl` delete permissions to a wrapped command (such as a coding agent) via `--allow-delete`. The grant is tied to the wrapped process, auto-reverts when it exits, and never covers the irreversible `organizations` and `projects` classes unless they are named explicitly.
* Adds the `harness exec` command, which lets a human grant session-scoped, noninteractive `tfctl` delete permissions to a wrapped command (such as a coding agent) via `--allow-delete`. The grant is tied to all subprocesses.


ENHANCEMENTS:
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

NEW FEATURES:

* Adds the `harness exec` command, which lets a human grant session-scoped, noninteractive `tfctl` delete permissions to a wrapped command (such as a coding agent) via `--allow-delete`. The grant is tied to the wrapped process, auto-reverts when it exits, and never covers the irreversible `organizations` and `projects` classes unless they are named explicitly.
* Adds the `harness exec` command, which lets a human grant session-scoped, noninteractive `tfctl` delete permissions to a wrapped command (such as a coding agent) via `--allow-delete`. The grant is tied to all subprocesses.


ENHANCEMENTS:
Expand Down
46 changes: 34 additions & 12 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ SHELL=/usr/bin/env bash
NAME=tfctl
BIN_PATH ?= dist/$(NAME)
ASSETS ?= assets
VERSION_FILE ?= version/VERSION
SKILL_HASHES = skills/tfctl/known_release_hashes
SKILL_EMBEDDED = skills/tfctl/SKILL.md

ifeq ($(GOARCH), arm64)
GOARCH = arm64
Expand Down Expand Up @@ -40,6 +43,11 @@ gen/screenshot: go/install # Create a screenshot of the tfctl CLI
gen/logo: logotools
@lolcat -S 26 -f <(figlet -d ./assets -f "Sub-Zero.flf" tfctl) > ./cmd/tfctl/logo.txt

.PHONY: gen/openapi
gen/openapi:
@curl -s -o ./internal/pkg/openapi/spec/hcpt_v2_public_beta.json https://app.terraform.io/openapi/prerelease.json
@echo "Embedded OpenAPI spec updated successfully"

.PHONY: go/build
go/build: bin

Expand All @@ -65,6 +73,15 @@ go/fmt:
fmt-check:
@test -z "$$(gofmt -s -l . | tee /dev/stderr)" || (echo "Code is not formatted. Run 'make go/fmt'" && exit 1)

# Release targets
.PHONY: prepare-release
prepare-release: gen/openapi
@if [ -z "$(VERSION)" ]; then echo "VERSION is not set"; exit 1; fi
@echo $(VERSION) > $(VERSION_FILE)
@echo "Updated $(VERSION_FILE) to $(VERSION)"
@echo "$$(shasum -a 256 $(SKILL_EMBEDDED) | cut -d' ' -f1) v$(VERSION)" >> $(SKILL_HASHES)
@echo "Appended sha256 for $(SKILL_EMBEDDED) to $(SKILL_HASHES)"

# Install development tools
.PHONY: tools
tools:
Expand Down Expand Up @@ -97,20 +114,25 @@ help:
@echo "Available targets:"
@echo ""
@echo "Tools:"
@echo " tools Install development tools"
@echo " gen/screenshot Generate a screenshot of the CLI in $(ASSETS)/"
@echo " gen/logo Generate the ASCII art logo"
@echo " tools Install development tools"
@echo " gen/screenshot Generate a screenshot of the CLI in $(ASSETS)/"
@echo " gen/logo Generate the ASCII art logo"
@echo ""
@echo "Build:"
@echo " go/install Install tfctl binary to GOPATH/bin"
@echo " bin Build a binary for tfctl ($(BIN_PATH))"
@echo " clean Clean build artifacts"
@echo " docker Build a docker image for tfctl"
@echo " go/install Install tfctl binary to GOPATH/bin"
@echo " bin Build a binary for tfctl ($(BIN_PATH))"
@echo " clean Clean build artifacts"
@echo " docker Build a docker image for tfctl"
@echo ""
@echo "Code:"
@echo " check Run all checks (formatting, linting, tests)"
@echo " go/test Run all tests"
@echo " go/lint Run golangci-lint"
@echo " go/fmt Format go code"
@echo " fmt-check Check go code formatting"
@echo " check Run all checks (formatting, linting, tests)"
@echo " go/test Run all tests"
@echo " go/lint Run golangci-lint"
@echo " go/fmt Format go code"
@echo " fmt-check Check go code formatting"
@echo ""
@echo "Release:"
@echo " gen/openapi Update embedded OpenAPI spec"
@echo " prepare-release Prepare next semantic version release,"
@echo " requires VERSION argument"
@echo ""
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@ The CLI stores configuration for individual profiles in the `profiles` subdirect

If you have not configured a token for the active profile with `tfctl auth login`, the `tfctl` CLI checks your Terraform configuration for a matching token. These tokens may either be in your Terraform configuration directory, for example `~/.terraform.d/credentials.tfrc.json`, or the corresponding Terraform environment variables, such as `TF_TOKEN_app_terraform_io`.

### Environment variables
### Profile Environment variables

If you have not configured a particular option for the active profile, `tfctl` checks the following environment variables:
If you have **not** configured a particular option for the active profile, `tfctl` checks the following environment variables:

`TFCTL_ORGANIZATION`: The organization to use for commands that require an organization.

Expand All @@ -179,6 +179,14 @@ If you have not configured a particular option for the active profile, `tfctl` c

`TF_TOKEN_<hostname>`: An HCP Terraform API token to present during authentication, with the specified hostname in Punycode formatting, for example `TF_TOKEN_app_terraform_io`. The CLI present the Terraform token only if it has not been configured in any other way.

### Behavior Environment Variables

`TFCTL_CONFIG_DIR`: Change the location of the tfctl config directory, which is `~/.config/tfctl` by default.

`TFCTL_SKIP_MIGRATE`: Don't migrate installed skill files to the latest version (if contents are known to be installed by a previous version).

`CHECKPOINT_DISABLE`: Don't check for newer versions of tfctl.

## Command reference

![tfctl](assets/tfctl.png "tfctl")
Expand Down
153 changes: 90 additions & 63 deletions cmd/tfctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ package main

import (
"context"
_ "embed"
"errors"
"fmt"
"os"
Expand All @@ -22,21 +21,60 @@ import (
"github.com/hashicorp/tfctl-cli/internal/pkg/checkpoint"
"github.com/hashicorp/tfctl-cli/internal/pkg/cmd"
"github.com/hashicorp/tfctl-cli/internal/pkg/format"
"github.com/hashicorp/tfctl-cli/internal/pkg/heredoc"
"github.com/hashicorp/tfctl-cli/internal/pkg/iostreams"
"github.com/hashicorp/tfctl-cli/internal/pkg/logging"
"github.com/hashicorp/tfctl-cli/internal/pkg/profile"
"github.com/hashicorp/tfctl-cli/internal/pkg/telemetry"
"github.com/hashicorp/tfctl-cli/skills"
"github.com/hashicorp/tfctl-cli/version"
)

//go:embed logo.txt
var Logo string
var (
envSkipMigrate = "TFCTL_SKIP_MIGRATE"
envCheckpointDisable = "CHECKPOINT_DISABLE"
)

func main() {
os.Exit(realMain())
}

func isGlobalBooleanArg(arg string, bareForm string) bool {
return arg == bareForm || arg == fmt.Sprintf("%s=true", bareForm)
}

func isDryRun(args []string) bool {
for _, a := range args {
if isGlobalBooleanArg(a, "--dry-run") {
return true
}
}
return false
}

func isVersion(args []string) bool {
if len(args) == 0 {
return true
}

allowVersionCommand := true
for _, arg := range args {
if !strings.HasPrefix(arg, "-") {
// A non-flag argument before version flags indicates that this is not a version request.
return false
}

if !isGlobalBooleanArg(arg, "--no-color") && !isGlobalBooleanArg(arg, "--debug") && !isGlobalBooleanArg(arg, "--quiet") {
allowVersionCommand = false
}

// Any of these coming first indicate a version command
if arg == "-v" || arg == "-version" || arg == "--version" {
return true
}
}
return allowVersionCommand
}

func realMain() int {
args := os.Args[1:]

Expand Down Expand Up @@ -65,29 +103,36 @@ func realMain() int {
// Explore relevant global args before the command parses them to set up non-command output
initialLogLevel := logging.LevelDefault
for _, a := range args {
if a == "--debug" {
if isGlobalBooleanArg(a, "--debug") {
initialLogLevel = logging.LevelDebug
}
if a == "--no-color" {
if isGlobalBooleanArg(a, "--no-color") {
io.ForceNoColor()
}
if a == "--quiet" {
if isGlobalBooleanArg(a, "--quiet") {
io.SetQuiet(true)
}
}

// The logger level will need to be set by the command after parsing flags.
// The actual logger level will be set by the command after parsing flags.
logger := logging.NewLogger(io, initialLogLevel)

// Add the logger to the shutdown context because this is the context used throughout
// the command execution lifecycle.
// Add the logger to the main context for use everywhere else.
shutdownCtx = logging.WithLogger(shutdownCtx, logger)

// Run the checkpoint request in a separate goroutine. It's important to always execute
// Checkpoint is HashiCorp's service for checking the current version against the
// latest, providing any relevant warnings about the current release in rare situations.
// Run the request in a separate goroutine. It's important to always execute
// this without condition because checkForNewVersion will block until it is complete
go checkpoint.Run(shutdownCtx, os.Getenv("CHECKPOINT_DISABLE") != "")
go checkpoint.Run(shutdownCtx, os.Getenv(envCheckpointDisable) != "")

// Create the profile loader
// Conditionally begin migrating any existing skills that match an older version to the embedded version.
var migration *skills.Migration
if !isDryRun(args) && os.Getenv(envSkipMigrate) == "" {
migration = skills.StartMigration(shutdownCtx)
}

// Create the profile loader and load the active profile.
loader, err := profile.NewLoader()
if err != nil {
fmt.Fprintln(io.Err(), err)
Expand Down Expand Up @@ -155,72 +200,54 @@ func realMain() int {
},
}

onlyFlagsInArgs := true
for _, arg := range args {
if !strings.HasPrefix(arg, "-") {
onlyFlagsInArgs = false
break
// Override the hashicorp/cli behavior of `tfctl --version` by rewriting the arguments to invoke the
// hidden "version" command. It's important not to call c.IsVersion() here because that would
// init the args, making overwriting them ineffective.
if isVersion(c.Args) || len(c.Args) == 0 {
newArgs := []string{"version"}
for _, arg := range c.Args {
// Strip all the possible version flags from the arguments
if arg != "--version" && arg != "-version" && arg != "-v" {
newArgs = append(newArgs, arg)
}
}
}

// If the user is running the root command, without --help or --version
// show the banner and exit.
if !c.IsVersion() && !c.IsHelp() && onlyFlagsInArgs {
showBanner(io)
return 0
c.Args = newArgs
}

status, err := c.Run()
if err != nil {
fmt.Fprintf(io.Err(), "Error executing %s: %s\n", version.Name, err.Error())
}

if status == 0 && c.IsVersion() {
checkForNewVersion(io)
}

// Don't worry about telemetry errors at all
if err = tel.Shutdown(shutdownCtx, status); err != nil {
logger.Debug("Error occurred while shutting down telemetry", "error", err)
}
shutdownMain(shutdownCtx, status, migration)

return status
}

func showBanner(io iostreams.IOStreams) {
if io.ColorEnabled() && io.IsOutputTTY() {
cs := io.ColorScheme()
// Prepends two spaces before every line of the logo and after the final line
fmt.Fprintf(io.ErrUnessential(), " %s", strings.Join(strings.Split(Logo, "\n"), "\n "))
fmt.Fprintf(io.ErrUnessential(), "%s\n", cs.String(version.Version).Color(cs.Purple()).Bold())
fmt.Fprintln(io.ErrUnessential(), "")
} else {
fmt.Fprintln(io.ErrUnessential(), version.Version)
}

fmt.Fprintln(io.Err(), heredoc.New(io).Mustf(`Get started by running {{ template "mdCodeOrBold" "%s auth login" }}
to authenticate with your user account or run {{ template "mdCodeOrBold" "%s --help" }} for usage
information. Release notes for this version are available at
{{ template "mdCodeOrBold" "https://github.com/hashicorp/tfctl-cli/blob/%s/CHANGELOG.md" }}
`, version.Name, version.Name, version.Version))
fmt.Fprintln(io.Err(), "")
func shutdownMain(ctx context.Context, exitCode int, migration *skills.Migration) {
logger := logging.FromContext(ctx)
tel := telemetry.FromContext(ctx)

checkForNewVersion(io)
}
// Wait for any ongoing skill migrations to complete
if migration != nil {
migrationResults, err := migration.Wait(ctx)
if err != nil {
logger.Debug("Skipped skill migration", "error", err)
}

func checkForNewVersion(io iostreams.IOStreams) {
cs := io.ColorScheme()
versionInfo := checkpoint.WaitForVersionCheck()
if versionInfo.Outdated {
fmt.Fprintf(io.ErrUnessential(), "A new version of %s is available: %s\n", version.Name, cs.String(fmt.Sprintf("v%s", versionInfo.Latest)).Color(cs.Purple()).Bold())
}
if len(versionInfo.Alerts) > 0 {
fmt.Fprintln(io.ErrUnessential(), "")
fmt.Fprintf(io.ErrUnessential(), "%s: %s\n", cs.WarningLabel(), "There are alerts regarding your current version.")
for _, alert := range versionInfo.Alerts {
fmt.Fprintln(io.ErrUnessential(), heredoc.New(io, heredoc.WithNoWrap()).Mustf(" - %s", alert))
for _, result := range migrationResults {
if result.FailedReason != nil {
logger.Error("Failed to migrate skill", "path", result.SkillPath, "reason", result.FailedReason.Error())
} else {
logger.Debug("Migrated skill", "path", result.SkillPath, "from", result.PreviousVersion)
}
}
}

// Don't worry about telemetry errors at all
if err := tel.Shutdown(ctx, exitCode); err != nil {
logger.Debug("Error occurred while shutting down telemetry", "error", err)
}
}

// loadActiveProfile loads the active profile.
Expand Down
Loading