From b7121b2a510ddea8587f9cc86a0ea9c313b1a58b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ey=C3=BE=C3=B3r=20Magn=C3=BAsson?= <eysi09@gmail.com>
Date: Mon, 13 Nov 2023 10:16:49 -0600
Subject: [PATCH] refactor: centralize styles for consistency and profit
 (#5375)

* chore(logger): centralize styles for consistency and profit

* chore(logger): remove unneeded calls to styles map

* chore(logger): better types for styles map + tweaks
---
 core/src/actions/base.ts                      |  6 +-
 core/src/actions/helpers.ts                   |  6 +-
 core/src/build-staging/build-staging.ts       |  3 +-
 core/src/cli/cli.ts                           | 23 +++---
 core/src/cli/command-line.ts                  | 49 ++++++-------
 core/src/cli/helpers.ts                       | 32 +++++----
 core/src/cli/params.ts                        | 12 ++--
 core/src/cloud/api.ts                         | 18 +++--
 core/src/commands/base.ts                     | 10 +--
 core/src/commands/cloud/groups/groups.ts      |  6 +-
 core/src/commands/cloud/helpers.ts            |  4 +-
 .../commands/cloud/secrets/secrets-list.ts    |  6 +-
 core/src/commands/cloud/users/users-list.ts   |  6 +-
 core/src/commands/create/create-project.ts    | 18 ++---
 core/src/commands/custom.ts                   |  6 +-
 core/src/commands/delete.ts                   |  5 +-
 core/src/commands/deploy.ts                   |  6 +-
 core/src/commands/dev.tsx                     | 34 +++++----
 core/src/commands/exec.ts                     | 18 +++--
 core/src/commands/get/get-actions.ts          |  6 +-
 core/src/commands/get/get-debug-info.ts       | 15 ++--
 core/src/commands/get/get-files.ts            |  4 +-
 core/src/commands/get/get-linked-repos.ts     | 12 ++--
 core/src/commands/get/get-modules.ts          | 10 +--
 core/src/commands/get/get-outputs.ts          |  8 +--
 core/src/commands/get/get-run-result.ts       |  4 +-
 core/src/commands/get/get-status.ts           |  8 +--
 core/src/commands/get/get-test-result.ts      |  8 ++-
 core/src/commands/helpers.ts                  |  6 +-
 core/src/commands/link/action.ts              |  4 +-
 core/src/commands/link/module.ts              |  4 +-
 core/src/commands/link/source.ts              |  4 +-
 core/src/commands/logs.ts                     |  8 +--
 core/src/commands/plugins.ts                  | 10 +--
 core/src/commands/run.ts                      | 18 ++---
 core/src/commands/self-update.ts              | 46 ++++++------
 core/src/commands/serve.ts                    | 18 ++---
 core/src/commands/set.ts                      |  6 +-
 core/src/commands/sync/sync-restart.ts        | 11 ++-
 core/src/commands/sync/sync-start.ts          | 17 ++---
 core/src/commands/sync/sync-status.ts         | 30 ++++----
 core/src/commands/sync/sync-stop.ts           |  7 +-
 core/src/commands/tools.ts                    | 16 ++---
 core/src/commands/up.ts                       |  6 +-
 core/src/commands/update-remote/actions.ts    |  4 +-
 core/src/commands/update-remote/modules.ts    |  4 +-
 core/src/commands/update-remote/sources.ts    |  4 +-
 core/src/commands/util/watch-parameter.ts     |  6 +-
 core/src/commands/validate.ts                 |  6 +-
 core/src/commands/workflow.ts                 | 36 +++++-----
 core/src/config/project.ts                    |  8 +--
 core/src/config/template-contexts/actions.ts  |  4 +-
 core/src/config/template-contexts/base.ts     | 12 ++--
 core/src/config/template-contexts/module.ts   |  4 +-
 core/src/config/template-contexts/project.ts  |  4 +-
 core/src/config/validation.ts                 | 20 ++++--
 core/src/exceptions.ts                        |  6 +-
 core/src/garden.ts                            | 26 ++++---
 core/src/graph/actions.ts                     | 10 +--
 core/src/graph/nodes.ts                       |  8 +--
 core/src/graph/solver.ts                      |  6 +-
 core/src/logger/log-entry.ts                  | 19 +++--
 core/src/logger/logger.ts                     |  2 +-
 core/src/logger/renderers.ts                  | 51 ++++++++-----
 core/src/logger/styles.ts                     | 72 +++++++++++++++++++
 core/src/logger/util.ts                       | 18 ++---
 core/src/monitors/logs.ts                     |  7 +-
 core/src/monitors/manager.ts                  |  5 +-
 core/src/monitors/port-forward.ts             |  6 +-
 core/src/mutagen.ts                           | 31 ++++----
 core/src/plugin/sdk.ts                        |  2 -
 core/src/plugins/container/helpers.ts         | 12 ++--
 core/src/plugins/exec/build.ts                |  6 +-
 core/src/plugins/exec/deploy.ts               | 12 ++--
 core/src/plugins/exec/run.ts                  |  7 +-
 core/src/plugins/exec/test.ts                 |  7 +-
 core/src/plugins/hadolint/hadolint.ts         | 12 ++--
 .../kubernetes/commands/cluster-init.ts       |  6 +-
 .../plugins/kubernetes/commands/pull-image.ts | 10 +--
 core/src/plugins/kubernetes/commands/sync.ts  |  4 +-
 .../commands/uninstall-garden-services.ts     |  6 +-
 .../kubernetes/container/build/buildkit.ts    |  4 +-
 .../kubernetes/container/build/common.ts      |  6 +-
 .../kubernetes/container/build/kaniko.ts      |  4 +-
 .../kubernetes/container/build/local.ts       |  3 +-
 .../kubernetes/container/deployment.ts        | 28 ++++----
 .../plugins/kubernetes/container/ingress.ts   |  3 +-
 .../plugins/kubernetes/ephemeral/config.ts    |  6 +-
 core/src/plugins/kubernetes/helm/exec.ts      |  4 +-
 .../kubernetes/kubernetes-type/exec.ts        |  4 +-
 .../kubernetes-type/kubernetes-exec.ts        |  7 +-
 core/src/plugins/kubernetes/local-mode.ts     | 42 ++++++-----
 core/src/plugins/kubernetes/local/microk8s.ts | 11 ++-
 core/src/plugins/kubernetes/namespace.ts      |  3 +-
 core/src/plugins/kubernetes/retry.ts          |  3 +-
 core/src/plugins/kubernetes/run-results.ts    |  3 +-
 core/src/plugins/kubernetes/status/pod.ts     |  6 +-
 .../src/plugins/kubernetes/status/workload.ts |  6 +-
 core/src/plugins/kubernetes/sync.ts           | 10 +--
 core/src/plugins/kubernetes/test-results.ts   |  3 +-
 core/src/plugins/kubernetes/util.ts           | 27 +++----
 core/src/proxy.ts                             |  9 +--
 core/src/resolve-module.ts                    | 13 ++--
 core/src/router/build.ts                      |  5 +-
 core/src/router/deploy.ts                     |  7 +-
 core/src/router/provider.ts                   |  4 +-
 core/src/router/router.ts                     |  5 +-
 core/src/server/instance-manager.ts           |  6 +-
 core/src/server/server.ts                     | 18 ++---
 core/src/tasks/build.ts                       |  3 +-
 core/src/tasks/deploy.ts                      |  9 ++-
 core/src/template-string/template-string.ts   |  4 +-
 core/src/util/artifacts.ts                    |  6 +-
 core/src/util/ext-source-util.ts              |  4 +-
 core/src/util/module-overlap.ts               | 20 +++---
 core/src/util/profiling.ts                    | 14 ++--
 core/src/util/serialization.ts                |  9 ++-
 core/src/util/testing.ts                      |  4 +-
 core/src/vcs/git.ts                           |  4 +-
 core/src/vcs/vcs.ts                           |  6 +-
 core/test/integ/helpers.ts                    |  8 +--
 core/test/unit/src/commands/logs.ts           |  4 +-
 core/test/unit/src/commands/publish.ts        |  4 +-
 core/test/unit/src/logger/log-entry.ts        |  8 +--
 core/test/unit/src/logger/logger.ts           |  4 +-
 core/test/unit/src/logger/renderers.ts        | 23 +++---
 .../unit/src/logger/writers/file-writer.ts    |  4 +-
 plugins/conftest/src/index.ts                 | 14 ++--
 plugins/pulumi/src/commands.ts                | 10 +--
 plugins/pulumi/src/handlers.ts                |  3 +-
 plugins/pulumi/src/helpers.ts                 |  6 +-
 plugins/terraform/src/commands.ts             | 18 ++---
 plugins/terraform/src/handlers.ts             |  4 +-
 plugins/terraform/src/helpers.ts              |  6 +-
 plugins/terraform/src/init.ts                 |  6 +-
 135 files changed, 768 insertions(+), 704 deletions(-)
 create mode 100644 core/src/logger/styles.ts

diff --git a/core/src/actions/base.ts b/core/src/actions/base.ts
index e345dd2099..0803a73524 100644
--- a/core/src/actions/base.ts
+++ b/core/src/actions/base.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import titleize from "titleize"
 import type { ConfigGraph, GetActionOpts, ResolvedConfigGraph } from "../graph/config-graph.js"
 import type { ActionReference, DeepPrimitiveMap } from "../config/common.js"
@@ -65,6 +64,7 @@ import { createActionLog } from "../logger/log-entry.js"
 import { joinWithPosix } from "../util/fs.js"
 import type { LinkedSource } from "../config-store/local.js"
 import type { BaseActionTaskParams, ExecuteTask } from "../tasks/base.js"
+import { styles } from "../logger/styles.js"
 
 // TODO: split this file
 
@@ -398,10 +398,10 @@ export abstract class BaseAction<
    * Verbose string description of the action. Useful for logging and error messages.
    */
   longDescription(): string {
-    let d = `${chalk.white(this.kind)} type=${chalk.bold.white(this.type)} name=${chalk.bold.white(this.name)}`
+    let d = `${styles.accent(this.kind)} type=${styles.accent.bold(this.type)} name=${styles.accent.bold(this.name)}`
 
     if (this._moduleName) {
-      d += ` (from module ${chalk.bold.white(this._moduleName)})`
+      d += ` (from module ${styles.accent.bold(this._moduleName)})`
     }
 
     return d
diff --git a/core/src/actions/helpers.ts b/core/src/actions/helpers.ts
index 145761d8f3..4f9b514942 100644
--- a/core/src/actions/helpers.ts
+++ b/core/src/actions/helpers.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { fromPairs, memoize } from "lodash-es"
 import { joi } from "../config/common.js"
 import type { Garden } from "../garden.js"
@@ -34,6 +33,7 @@ import type { ActionStatusPayload } from "../events/action-status-events.js"
 import type { BuildStatusForEventPayload } from "../plugin/handlers/Build/get-status.js"
 import type { DeployStatusForEventPayload } from "../types/service.js"
 import type { RunStatusForEventPayload } from "../plugin/plugin.js"
+import { styles } from "../logger/styles.js"
 
 /**
  * Creates a corresponding Resolved version of the given Action, given the additional parameters needed.
@@ -91,12 +91,12 @@ export async function warnOnLinkedActions(garden: Garden, log: Log, actions: Act
 
   const linkedActionsMsg = actions
     .filter((a) => a.isLinked(linkedSources))
-    .map((a) => `${a.longDescription()} linked to path ${chalk.white(a.sourcePath())}`)
+    .map((a) => `${a.longDescription()} linked to path ${styles.accent(a.sourcePath())}`)
     .map((msg) => "  " + msg) // indent list
 
   if (linkedActionsMsg.length > 0) {
     log.info(renderDivider())
-    log.info(chalk.gray(`The following actions are linked to a local path:\n${linkedActionsMsg.join("\n")}`))
+    log.info(styles.primary(`The following actions are linked to a local path:\n${linkedActionsMsg.join("\n")}`))
     log.info(renderDivider())
   }
 }
diff --git a/core/src/build-staging/build-staging.ts b/core/src/build-staging/build-staging.ts
index 3dc01ed1d8..9fa18f28ae 100644
--- a/core/src/build-staging/build-staging.ts
+++ b/core/src/build-staging/build-staging.ts
@@ -14,7 +14,6 @@ import { normalizeRelativePath, joinWithPosix } from "../util/fs.js"
 import type { Log } from "../logger/log-entry.js"
 import { Profile } from "../util/profiling.js"
 import async from "async"
-import chalk from "chalk"
 import { hasMagic } from "glob"
 import type { MappedPaths } from "./helpers.js"
 import { FileStatsHelper, syncFileAsync, cloneFile, scanDirectoryForClone } from "./helpers.js"
@@ -223,7 +222,7 @@ export class BuildStaging {
       }
 
       if (!sourceStat) {
-        log.warn(chalk.yellow(`Build staging: Could not find source file or directory at path ${sourceRoot}`))
+        log.warn(`Build staging: Could not find source file or directory at path ${sourceRoot}`)
         return
       }
     }
diff --git a/core/src/cli/cli.ts b/core/src/cli/cli.ts
index 0c38a154e5..36731b851d 100644
--- a/core/src/cli/cli.ts
+++ b/core/src/cli/cli.ts
@@ -8,7 +8,6 @@
 
 import { intersection, mapValues, sortBy } from "lodash-es"
 import { resolve, join } from "path"
-import chalk from "chalk"
 import fsExtra from "fs-extra"
 const { pathExists } = fsExtra
 import { getBuiltinCommands } from "../commands/commands.js"
@@ -60,6 +59,7 @@ import { withSessionContext } from "../util/open-telemetry/context.js"
 import { wrapActiveSpan } from "../util/open-telemetry/spans.js"
 import { JsonFileWriter } from "../logger/writers/json-file-writer.js"
 import type minimist from "minimist"
+import { styles } from "../logger/styles.js"
 
 export interface RunOutput {
   argv: any
@@ -109,7 +109,7 @@ export class GardenCli {
     // Thus we have to dedent like this.
     let msg = `
 ${cliStyles.heading("USAGE")}
-  garden ${cliStyles.commandPlaceholder()} ${cliStyles.optionsPlaceholder()}
+  garden ${cliStyles.commandPlaceholder()} ${cliStyles.argumentsPlaceholder()} ${cliStyles.optionsPlaceholder()}
 
 ${cliStyles.heading("COMMANDS")}
 ${renderCommands(commands)}
@@ -251,7 +251,7 @@ ${renderCommands(commands)}
         } catch (err) {
           if (err instanceof CloudApiTokenRefreshError) {
             log.warn(dedent`
-              ${chalk.yellow(`Unable to authenticate against ${distroName} with the current session token.`)}
+              Unable to authenticate against ${distroName} with the current session token.
               Command results for this command run will not be available in ${distroName}. If this not a
               ${distroName} project you can ignore this warning. Otherwise, please try logging out with
               \`garden logout\` and back in again with \`garden login\`.
@@ -314,18 +314,23 @@ ${renderCommands(commands)}
             await garden.emitWarning({
               key: "0.13-bonsai",
               log,
-              message: chalk.yellow(dedent`
+              message: dedent`
                 Garden v0.13 (Bonsai) is a major release with significant changes. Please help us improve it by reporting any issues/bugs here:
                 https://go.garden.io/report-bonsai
-              `),
+              `,
             })
           }
 
-          nsLog.info(`Running in Garden environment ${chalk.cyan(`${garden.environmentName}.${garden.namespace}`)}`)
+          nsLog.info(
+            `Running in Garden environment ${styles.highlight(`${garden.environmentName}.${garden.namespace}`)}`
+          )
 
           if (!cloudApi && garden.projectId) {
+            log.info("")
             log.warn(
-              `You are not logged in into Garden Cloud. Please log in via the ${chalk.green("garden login")} command.`
+              `Warning: You are not logged in into Garden Cloud. Please log in via the ${styles.command(
+                "garden login"
+              )} command.`
             )
             log.info("")
           }
@@ -376,7 +381,7 @@ ${renderCommands(commands)}
 
         if (garden.monitors.anyMonitorsActive()) {
           // Wait for monitors to exit
-          log.debug(chalk.gray("One or more monitors active, waiting until all exit."))
+          log.debug(styles.primary("One or more monitors active, waiting until all exit."))
           await garden.monitors.waitUntilStopped()
         }
 
@@ -438,7 +443,7 @@ ${renderCommands(commands)}
     const workingDir = resolve(cwd || process.cwd(), argv.root || "")
 
     if (!(await pathExists(workingDir))) {
-      return done(1, chalk.red(`Could not find specified root path (${argv.root})`))
+      return done(1, styles.error(`Could not find specified root path (${argv.root})`))
     }
 
     let projectConfig: ProjectConfig | undefined
diff --git a/core/src/cli/command-line.ts b/core/src/cli/command-line.ts
index 4b336b2c50..444d8d47dd 100644
--- a/core/src/cli/command-line.ts
+++ b/core/src/cli/command-line.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type { Key } from "ink"
 import { max } from "lodash-es"
 import { resolve } from "path"
@@ -40,14 +39,15 @@ import type { GlobalOptions, ParameterObject, ParameterValues } from "./params.j
 import { bindActiveContext, withSessionContext } from "../util/open-telemetry/context.js"
 import { wrapActiveSpan } from "../util/open-telemetry/spans.js"
 import { DEFAULT_BROWSER_DIVIDER_WIDTH } from "../constants.js"
+import { styles } from "../logger/styles.js"
 
 const defaultMessageDuration = 3000
-const commandLinePrefix = chalk.yellow("🌼  > ")
-const emptyCommandLinePlaceholder = chalk.gray("<enter command> (enter help for more info)")
+const commandLinePrefix = styles.warning("🌼  > ")
+const emptyCommandLinePlaceholder = styles.primary("<enter command> (enter help for more info)")
 const inputHistoryLength = 100
 
-const styles = {
-  command: chalk.white.bold,
+const commandLineStyles = {
+  command: styles.accent.bold,
 }
 
 export type SetStringCallback = (data: string) => void
@@ -76,15 +76,16 @@ interface CommandLineEvents {
 function getCmdsRunningMsg(commandNames: string[]) {
   let msg = ""
   if (commandNames.length === 1) {
-    msg = chalk.cyan(`Running ${styles.command(commandNames[0])} command...`)
+    msg = styles.highlight(`Running ${commandLineStyles.command(commandNames[0])} command...`)
   } else if (commandNames.length > 1) {
-    msg = chalk.cyan(`Running ${commandNames.length} commands: `) + styles.command(commandNames.join(", "))
+    msg =
+      styles.highlight(`Running ${commandNames.length} commands: `) + commandLineStyles.command(commandNames.join(", "))
   }
   return msg
 }
 
 function getCmdSuccessMsg(commandName: string) {
-  return `${chalk.cyan(commandName)} command completed successfully!`
+  return `${styles.highlight(commandName)} command completed successfully!`
 }
 
 function getCmdFailMsg(commandName: string) {
@@ -99,7 +100,7 @@ function getCmdFailMsg(commandName: string) {
  * by the web UI.
  */
 function logCommand({ msg, log, width, error }: { msg: string; log: Log; width: number; error: boolean }) {
-  const dividerColor = error ? chalk.red : chalk.blueBright
+  const dividerColor = error ? styles.error : styles.highlight
   const dividerOptsBase = { width, title: msg, color: dividerColor, char: "β”ˆ" }
   const terminalMsg = renderDivider(dividerOptsBase)
   const rawMsg = renderDivider({ ...dividerOptsBase, width: DEFAULT_BROWSER_DIVIDER_WIDTH })
@@ -428,18 +429,18 @@ export class CommandLine extends TypedEventEmitter<CommandLineEvents> {
     const suggestions = this.getSuggestions(this.currentCommand.length)
 
     if (this.isSuggestedCommand(suggestions)) {
-      renderedCommand = chalk.cyan(renderedCommand)
+      renderedCommand = styles.highlight(renderedCommand)
     }
 
     if (suggestions.length > 0) {
       // Show autocomplete suggestion after string
-      renderedCommand = renderedCommand + chalk.gray(suggestions[0].line.substring(renderedCommand.length))
+      renderedCommand = renderedCommand + styles.primary(suggestions[0].line.substring(renderedCommand.length))
     }
 
     if (renderedCommand.length === 0) {
       if (this.showCursor) {
         renderedCommand =
-          chalk.underline(sliceAnsi(emptyCommandLinePlaceholder, 0, 1)) + sliceAnsi(emptyCommandLinePlaceholder, 1)
+          styles.underline(sliceAnsi(emptyCommandLinePlaceholder, 0, 1)) + sliceAnsi(emptyCommandLinePlaceholder, 1)
       } else {
         renderedCommand = emptyCommandLinePlaceholder
       }
@@ -450,7 +451,7 @@ export class CommandLine extends TypedEventEmitter<CommandLineEvents> {
 
       renderedCommand =
         sliceAnsi(renderedCommand, 0, this.cursorPosition) +
-        (this.showCursor ? chalk.underline(cursorChar) : cursorChar) +
+        (this.showCursor ? styles.underline(cursorChar) : cursorChar) +
         sliceAnsi(renderedCommand, this.cursorPosition + 1)
     }
 
@@ -489,13 +490,13 @@ export class CommandLine extends TypedEventEmitter<CommandLineEvents> {
     }
 
     const char = "β”ˆ"
-    const color = chalk.bold
+    const color = styles.bold
 
     // `dedent` has a bug where it doesn't indent correctly
     // when there's ANSI codes in the beginning of a line.
     // Thus we have to dedent like this.
     const wrapped = `
-${renderDivider({ title: chalk.bold(title), width, char, color })}
+${renderDivider({ title: styles.bold(title), width, char, color })}
 ${text}
 ${renderDivider({ width, char, color })}
 `
@@ -509,17 +510,17 @@ ${renderDivider({ width, char, color })}
     })
 
     const helpText = `
-${chalk.white.underline("Popular commands:")}
+${styles.accent.underline("Popular commands:")}
 
 ${renderCommands(getPopularCommands(commandsToRender))}
 
-${chalk.white.underline("Other commands:")}
+${styles.accent.underline("Other commands:")}
 
 ${renderCommands(getOtherCommands(commandsToRender))}
 
-${chalk.white.underline("Keys:")}
+${styles.accent.underline("Keys:")}
 
-  ${chalk.gray(`[tab]: auto-complete  [up/down]: command history  [ctrl-u]: clear line  [ctrl-d]: quit`)}
+  ${styles.primary(`[tab]: auto-complete  [up/down]: command history  [ctrl-u]: clear line  [ctrl-d]: quit`)}
 `
     this.printWithDividers(helpText, "help")
   }
@@ -543,15 +544,15 @@ ${chalk.white.underline("Keys:")}
   }
 
   flashSuccess(message: string, opts: FlashOpts = {}) {
-    this.flashMessage(chalk.green(message), { prefix: chalk.green("βœ”οΈŽ  "), ...opts })
+    this.flashMessage(styles.success(message), { prefix: styles.success("βœ”οΈŽ  "), ...opts })
   }
 
   flashError(message: string, opts: FlashOpts = {}) {
-    this.flashMessage(chalk.red(message), { prefix: "❗️  ", ...opts })
+    this.flashMessage(styles.error(message), { prefix: "❗️  ", ...opts })
   }
 
   flashWarning(message: string, opts: FlashOpts = {}) {
-    this.flashMessage(chalk.yellowBright(message), { prefix: chalk.yellow("⚠️  "), ...opts })
+    this.flashMessage(styles.warning(message), { prefix: styles.warning("⚠️  "), ...opts })
   }
 
   setKeyHandler(stringKey: string, handler: KeyHandler) {
@@ -608,7 +609,7 @@ ${chalk.white.underline("Keys:")}
     const { command, rest, matchedPath } = pickCommand(this.getCommands(), rawArgs)
 
     if (!command) {
-      this.flashError(`Could not find command. Try typing ${chalk.white("help")} to see the available commands.`)
+      this.flashError(`Could not find command. Try typing ${styles.accent("help")} to see the available commands.`)
       return
     }
 
@@ -775,7 +776,7 @@ ${chalk.white.underline("Keys:")}
           // Update persisted history
           // Note: We're currently not resolving history across concurrent dev commands, but that's anyway not well supported
           garden.localConfigStore.set("devCommandHistory", this.commandHistory).catch((error) => {
-            this.log.warn(chalk.yellow(`Could not persist command history: ${error}`))
+            this.log.warn(`Could not persist command history: ${error}`)
           })
 
           command
diff --git a/core/src/cli/helpers.ts b/core/src/cli/helpers.ts
index 606a5d9025..14a53cf191 100644
--- a/core/src/cli/helpers.ts
+++ b/core/src/cli/helpers.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import ci from "ci-info"
 import dotenv from "dotenv"
 import fsExtra from "fs-extra"
@@ -35,20 +34,22 @@ import { globalOptions } from "./params.js"
 import type { BuiltinArgs, Command, CommandGroup } from "../commands/base.js"
 import type { DeepPrimitiveMap } from "../config/common.js"
 import { validateGitInstall } from "../vcs/vcs.js"
+import { styles } from "../logger/styles.js"
 
 export const cliStyles = {
-  heading: (str: string) => chalk.white.bold(str),
-  commandPlaceholder: () => chalk.blueBright("<command>"),
-  optionsPlaceholder: () => chalk.yellowBright("[options]"),
-  hints: (str: string) => chalk.gray(str),
+  heading: (str: string) => styles.accent.bold(str),
+  commandPlaceholder: () => styles.command("<command>"),
+  argumentsPlaceholder: () => styles.highlight("[arguments]"),
+  optionsPlaceholder: () => styles.warning("[options]"),
+  hints: (str: string) => styles.primary(str),
   usagePositional: (key: string, required: boolean, spread: boolean) => {
     if (spread) {
       key += " ..."
     }
 
-    return chalk.cyan(required ? `<${key}>` : `[${key}]`)
+    return styles.highlight(required ? `<${key}>` : `[${key}]`)
   },
-  usageOption: (str: string) => chalk.cyan(`<${str}>`),
+  usageOption: (str: string) => styles.highlight(`<${str}>`),
 }
 
 /**
@@ -253,7 +254,7 @@ export function processCliArgs<A extends ParameterObject, O extends ParameterObj
     // Ensure all required positional arguments are present
     if (!argVal) {
       if (spec.required) {
-        errors.push(`Missing required argument ${chalk.white.bold(argKey)}`)
+        errors.push(`Missing required argument ${styles.accent.bold(argKey)}`)
       }
 
       // Commands expect unused arguments to be explicitly set to undefined.
@@ -281,7 +282,8 @@ export function processCliArgs<A extends ParameterObject, O extends ParameterObj
     } else if (command.allowUndefinedArguments) {
       continue
     } else {
-      const expected = argKeys.length > 0 ? "only " + naturalList(argKeys.map((key) => chalk.white.bold(key))) : "none"
+      const expected =
+        argKeys.length > 0 ? "only " + naturalList(argKeys.map((key) => styles.accent.bold(key))) : "none"
 
       throw new ParameterError({
         message: `Unexpected positional argument "${argVal}" (expected ${expected})`,
@@ -301,7 +303,7 @@ export function processCliArgs<A extends ParameterObject, O extends ParameterObj
       }
     } catch (error) {
       throw new ParameterError({
-        message: `Invalid value for argument ${chalk.white.bold(argKey)}: ${error}`,
+        message: `Invalid value for argument ${styles.accent.bold(argKey)}: ${error}`,
       })
     }
   }
@@ -328,7 +330,7 @@ export function processCliArgs<A extends ParameterObject, O extends ParameterObj
     }
 
     const spec = optsWithAliases[key]
-    const flagStr = chalk.white.bold(key.length === 1 ? "-" + key : "--" + key)
+    const flagStr = styles.accent.bold(key.length === 1 ? "-" + key : "--" + key)
 
     if (!spec) {
       if (command.allowUndefinedArguments && value !== undefined) {
@@ -369,7 +371,7 @@ export function processCliArgs<A extends ParameterObject, O extends ParameterObj
 
   if (errors.length > 0) {
     throw new ParameterError({
-      message: chalk.red.bold(errors.join("\n")),
+      message: styles.error.bold(errors.join("\n")),
     })
   }
 
@@ -482,7 +484,7 @@ export function renderCommands(commands: Command[]) {
   }
 
   const rows = commands.map((command) => {
-    return [` ${chalk.cyan(command.getFullName())}`, command.help]
+    return [` ${styles.command(command.getFullName())}`, command.help]
   })
 
   const maxCommandLength = max(rows.map((r) => r[0]!.length))!
@@ -511,7 +513,7 @@ export function renderOptions(params: ParameterObject) {
     // Note: If there is more than one alias we don't actually want to print them all in help texts,
     // since generally they're there for backwards compatibility more than normal usage.
     const renderedAlias = renderAlias(param.aliases?.[0])
-    return chalk.green(` ${renderedAlias}--${name} `)
+    return styles.warning(` ${renderedAlias}--${name} `)
   })
 }
 
@@ -532,7 +534,7 @@ function renderParameters(params: ParameterObject, formatName: (name: string, pa
         hints += ` [default: ${param.defaultValue}]`
       }
     }
-    return out + chalk.gray(hints)
+    return out + styles.primary(hints)
   })
 
   const nameColWidth = stringWidth(maxBy(names, (n) => stringWidth(n)) || "") + 2
diff --git a/core/src/cli/params.ts b/core/src/cli/params.ts
index fa25e9040f..2c7e760eab 100644
--- a/core/src/cli/params.ts
+++ b/core/src/cli/params.ts
@@ -16,13 +16,13 @@ import { ParameterError } from "../exceptions.js"
 import { parseEnvironment } from "../config/project.js"
 import { getLogLevelChoices, LOGGER_TYPES, LogLevel } from "../logger/logger.js"
 import { dedent, deline } from "../util/string.js"
-import chalk from "chalk"
 import { safeDumpYaml } from "../util/serialization.js"
 import { resolve } from "path"
 import { isArray } from "lodash-es"
 import { gardenEnv } from "../constants.js"
 import { envSupportsEmoji } from "../logger/util.js"
 import type { ConfigDump } from "../garden.js"
+import { styles } from "../logger/styles.js"
 
 export const OUTPUT_RENDERERS = {
   json: (data: DeepPrimitiveMap) => {
@@ -377,12 +377,12 @@ export const globalDisplayOptions = {
     choices: [...LOGGER_TYPES],
     help: deline`
       Set logger type.
-      ${chalk.bold("default")} The default Garden logger,
-      ${chalk.bold(
+      ${styles.bold("default")} The default Garden logger,
+      ${styles.bold(
         "basic"
       )} [DEPRECATED] Sames as the default Garden logger. This option will be removed in a future release,
-      ${chalk.bold("json")} same as default, but renders log lines as JSON,
-      ${chalk.bold("quiet")} suppresses all log output, same as --silent.
+      ${styles.bold("json")} same as default, but renders log lines as JSON,
+      ${styles.bold("quiet")} suppresses all log output, same as --silent.
     `,
     cliOnly: true,
   }),
@@ -407,7 +407,7 @@ export const globalDisplayOptions = {
   }),
   "show-timestamps": new BooleanParameter({
     help: deline`
-      Show timestamps with log output. When enabled, Garden will use the ${chalk.bold(
+      Show timestamps with log output. When enabled, Garden will use the ${styles.bold(
         "basic"
       )} logger. I.e., log status changes are rendered as new lines instead of being updated in-place.`,
     defaultValue: false,
diff --git a/core/src/cloud/api.ts b/core/src/cloud/api.ts
index c0071accd4..5d6c0929ea 100644
--- a/core/src/cloud/api.ts
+++ b/core/src/cloud/api.ts
@@ -33,7 +33,7 @@ import { add } from "date-fns"
 import { LogLevel } from "../logger/logger.js"
 import { makeAuthHeader } from "./auth.js"
 import type { StringMap } from "../config/common.js"
-import chalk from "chalk"
+import { styles } from "../logger/styles.js"
 
 const gardenClientName = "garden-core"
 const gardenClientVersion = getPackageVersion()
@@ -777,26 +777,24 @@ export class CloudApi {
       }
       // This happens if an environment or project does not exist
       if (err.response.statusCode === 404) {
-        const errorHeaderMsg = chalk.red(`Unable to read secrets from ${distroName}.`)
-        const errorDetailMsg = chalk.white(dedent`
-          Either the environment ${chalk.bold.whiteBright(environmentName)} does not exist in ${distroName},
-          or no project matches the project ID ${chalk.bold.whiteBright(
-            projectId
-          )} in your project level garden.yml file.
+        const errorHeaderMsg = styles.error(`Unable to read secrets from ${distroName}.`)
+        const errorDetailMsg = styles.accent(dedent`
+          Either the environment ${styles.accent.bold(environmentName)} does not exist in ${distroName},
+          or no project matches the project ID ${styles.accent.bold(projectId)} in your project level garden.yml file.
 
           πŸ’‘Suggestion:
 
-          Visit ${chalk.underline(this.domain)} to review existing environments and projects.
+          Visit ${styles.link(this.domain)} to review existing environments and projects.
 
           First check whether an environment with name ${environmentName} exists for this project. You
           can view the list of environments and the project ID on the project's Settings page.
 
-          ${chalk.bold.whiteBright(
+          ${styles.accent.bold(
             "If the environment does not exist"
           )}, you can either create one from the Settings page or update
           the environments in your project level garden.yml config to match one that already exists.
 
-          ${chalk.bold.whiteBright(
+          ${styles.accent.bold(
             "If a project with this ID does not exist"
           )}, it's likely because the ID has been changed in the
           project level garden.yml config file or the project has been deleted from ${distroName}.
diff --git a/core/src/commands/base.ts b/core/src/commands/base.ts
index cb6e8619a9..a12d89bbdd 100644
--- a/core/src/commands/base.ts
+++ b/core/src/commands/base.ts
@@ -7,7 +7,6 @@
  */
 
 import type Joi from "@hapi/joi"
-import chalk from "chalk"
 import dedent from "dedent"
 import stripAnsi from "strip-ansi"
 import { flatMap, fromPairs, mapValues, pickBy, size } from "lodash-es"
@@ -46,6 +45,7 @@ import type { ActionMode } from "../actions/types.js"
 import type { AnalyticsHandler } from "../analytics/analytics.js"
 import { withSessionContext } from "../util/open-telemetry/context.js"
 import { wrapActiveSpan } from "../util/open-telemetry/spans.js"
+import { styles } from "../logger/styles.js"
 
 export interface CommandConstructor {
   new (parent?: CommandGroup): Command
@@ -323,7 +323,7 @@ export abstract class Command<
           }).href
           const cloudLog = log.createLog({ name: getCloudLogSectionName(distroName) })
 
-          cloudLog.info(`View command results at: ${chalk.cyan(commandResultUrl)}\n`)
+          cloudLog.info(`View command results at: ${styles.highlight(commandResultUrl)}\n`)
         }
 
         let analytics: AnalyticsHandler | undefined
@@ -580,7 +580,7 @@ export abstract class Command<
    */
   async isAllowedToRun(garden: Garden, log: Log, opts: ParameterValues<GlobalOptions>): Promise<boolean> {
     if (!opts.yes && this.protected && garden.production) {
-      const defaultMessage = chalk.yellow(dedent`
+      const defaultMessage = styles.warning(dedent`
         Warning: you are trying to run "garden ${this.getFullName()}" against a production environment ([${
           garden.environmentName
         }])!
@@ -604,10 +604,10 @@ export abstract class Command<
 
   renderHelp() {
     let out = this.description
-      ? `\n${cliStyles.heading("DESCRIPTION")}\n\n${chalk.dim(this.description.trim())}\n\n`
+      ? `\n${cliStyles.heading("DESCRIPTION")}\n\n${styles.secondary(this.description.trim())}\n\n`
       : ""
 
-    out += `${cliStyles.heading("USAGE")}\n  garden ${this.getFullName()} `
+    out += `${cliStyles.heading("USAGE")}\n  garden ${styles.command(this.getFullName())} `
 
     if (this.arguments) {
       out +=
diff --git a/core/src/commands/cloud/groups/groups.ts b/core/src/commands/cloud/groups/groups.ts
index 67ada8489e..0d10b5e6cc 100644
--- a/core/src/commands/cloud/groups/groups.ts
+++ b/core/src/commands/cloud/groups/groups.ts
@@ -7,7 +7,6 @@
  */
 
 import type { ListGroupsResponse } from "@garden-io/platform-api-types"
-import chalk from "chalk"
 import { sortBy } from "lodash-es"
 import { StringsParameter } from "../../../cli/params.js"
 import { ConfigurationError } from "../../../exceptions.js"
@@ -16,6 +15,7 @@ import { dedent, deline, renderTable } from "../../../util/string.js"
 import type { CommandParams, CommandResult } from "../../base.js"
 import { Command, CommandGroup } from "../../base.js"
 import { noApiMsg, applyFilter } from "../helpers.js"
+import { styles } from "../../../logger/styles.js"
 
 // TODO: Add created at and updated at timestamps. Need to add it to the API response first.
 interface Groups {
@@ -91,9 +91,9 @@ export class GroupsListCommand extends Command<{}, Opts> {
 
     log.debug(`Found ${filtered.length} groups that match filters`)
 
-    const heading = ["Name", "ID", "Default Admin Group"].map((s) => chalk.bold(s))
+    const heading = ["Name", "ID", "Default Admin Group"].map((s) => styles.bold(s))
     const rows: string[][] = filtered.map((g) => {
-      return [chalk.cyan.bold(g.name), String(g.id), String(g.defaultAdminGroup)]
+      return [styles.highlight.bold(g.name), String(g.id), String(g.defaultAdminGroup)]
     })
 
     log.info(renderTable([heading].concat(rows)))
diff --git a/core/src/commands/cloud/helpers.ts b/core/src/commands/cloud/helpers.ts
index 35bace43f4..4722453ed1 100644
--- a/core/src/commands/cloud/helpers.ts
+++ b/core/src/commands/cloud/helpers.ts
@@ -13,10 +13,10 @@ import type { Log } from "../../logger/log-entry.js"
 import { capitalize } from "lodash-es"
 import minimatch from "minimatch"
 import pluralize from "pluralize"
-import chalk from "chalk"
 import { CommandError, toGardenError } from "../../exceptions.js"
 import type { CommandResult } from "../base.js"
 import { userPrompt } from "../../util/util.js"
+import { styles } from "../../logger/styles.js"
 
 export interface DeleteResult {
   id: string | number
@@ -176,7 +176,7 @@ export function applyFilter(filter: string[], val?: string | string[]) {
 }
 
 export async function confirmDelete(resource: string, count: number) {
-  const msg = chalk.yellow(dedent`
+  const msg = styles.warning(dedent`
     Warning: you are about to delete ${count} ${
       count === 1 ? resource : pluralize(resource)
     }. This operation cannot be undone.
diff --git a/core/src/commands/cloud/secrets/secrets-list.ts b/core/src/commands/cloud/secrets/secrets-list.ts
index 642299cc58..5cd813fc43 100644
--- a/core/src/commands/cloud/secrets/secrets-list.ts
+++ b/core/src/commands/cloud/secrets/secrets-list.ts
@@ -16,12 +16,12 @@ import type { CommandParams, CommandResult } from "../../base.js"
 import { Command } from "../../base.js"
 import type { SecretResult } from "../helpers.js"
 import { applyFilter, makeSecretFromResponse, noApiMsg } from "../helpers.js"
-import chalk from "chalk"
 import { sortBy } from "lodash-es"
 import { StringsParameter } from "../../../cli/params.js"
 import { getCloudDistributionName } from "../../../util/util.js"
 import type { CloudApi, CloudProject } from "../../../cloud/api.js"
 import type { Log } from "../../../logger/log-entry.js"
+import { styles } from "../../../logger/styles.js"
 
 export const fetchAllSecrets = async (api: CloudApi, projectId: string, log: Log): Promise<SecretResult[]> => {
   let page = 0
@@ -118,10 +118,10 @@ export class SecretsListCommand extends Command<{}, Opts> {
 
     log.debug(`Found ${filtered.length} secrets that match filters`)
 
-    const heading = ["Name", "ID", "Environment", "User", "Created At"].map((s) => chalk.bold(s))
+    const heading = ["Name", "ID", "Environment", "User", "Created At"].map((s) => styles.bold(s))
     const rows: string[][] = filtered.map((s) => {
       return [
-        chalk.cyan.bold(s.name),
+        styles.highlight.bold(s.name),
         String(s.id),
         s.environment?.name || "[none]",
         s.user?.name || "[none]",
diff --git a/core/src/commands/cloud/users/users-list.ts b/core/src/commands/cloud/users/users-list.ts
index 1943cff349..7951db1399 100644
--- a/core/src/commands/cloud/users/users-list.ts
+++ b/core/src/commands/cloud/users/users-list.ts
@@ -15,11 +15,11 @@ import type { CommandParams, CommandResult } from "../../base.js"
 import { Command } from "../../base.js"
 import type { UserResult } from "../helpers.js"
 import { applyFilter, makeUserFromResponse, noApiMsg } from "../helpers.js"
-import chalk from "chalk"
 import { sortBy } from "lodash-es"
 import { StringsParameter } from "../../../cli/params.js"
 import { getCloudDistributionName } from "../../../util/util.js"
 import type { CloudProject } from "../../../cloud/api.js"
+import { styles } from "../../../logger/styles.js"
 
 export const usersListOpts = {
   "filter-names": new StringsParameter({
@@ -115,10 +115,10 @@ export class UsersListCommand extends Command<{}, Opts> {
 
     log.debug(`Found ${filtered.length} users that match filters`)
 
-    const heading = ["Name", "ID", `${vcsProviderTitle} Username`, "Groups", "Created At"].map((s) => chalk.bold(s))
+    const heading = ["Name", "ID", `${vcsProviderTitle} Username`, "Groups", "Created At"].map((s) => styles.bold(s))
     const rows: string[][] = filtered.map((u) => {
       return [
-        chalk.cyan.bold(u.name),
+        styles.highlight.bold(u.name),
         String(u.id),
         u.vcsUsername || "",
         u.groups.map((g) => g.name).join(", "),
diff --git a/core/src/commands/create/create-project.ts b/core/src/commands/create/create-project.ts
index 45949cb31f..0cdae3290f 100644
--- a/core/src/commands/create/create-project.ts
+++ b/core/src/commands/create/create-project.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import dedent from "dedent"
 import fsExtra from "fs-extra"
 const { pathExists, writeFile, copyFile } = fsExtra
@@ -22,6 +21,7 @@ import { wordWrap } from "../../util/string.js"
 import { PathParameter, StringParameter, BooleanParameter, StringOption } from "../../cli/params.js"
 import { userPrompt } from "../../util/util.js"
 import { DOCS_BASE_URL, GardenApiVersion } from "../../constants.js"
+import { styles } from "../../logger/styles.js"
 
 const ignorefileName = ".gardenignore"
 const defaultIgnorefile = dedent`
@@ -196,7 +196,9 @@ export class CreateProjectCommand extends Command<CreateProjectArgs, CreateProje
 
     await addConfig(configPath, yaml)
 
-    log.info(chalk.green(`-> Created new project config in ${chalk.bold.white(relative(process.cwd(), configPath))}`))
+    log.info(
+      styles.success(`-> Created new project config in ${styles.accent.bold(relative(process.cwd(), configPath))}`)
+    )
 
     const ignoreFilePath = resolve(configDir, ignorefileName)
     let ignoreFileCreated = false
@@ -206,17 +208,17 @@ export class CreateProjectCommand extends Command<CreateProjectArgs, CreateProje
 
       if (await pathExists(gitIgnorePath)) {
         await copyFile(gitIgnorePath, ignoreFilePath)
-        const gitIgnoreRelPath = chalk.bold.white(relative(process.cwd(), ignoreFilePath))
+        const gitIgnoreRelPath = styles.accent.bold(relative(process.cwd(), ignoreFilePath))
         log.info(
-          chalk.green(
+          styles.success(
             `-> Copied the .gitignore file at ${gitIgnoreRelPath} to a new .gardenignore in the same directory. Please edit the .gardenignore file if you'd like Garden to include or ignore different files.`
           )
         )
       } else {
         await writeFile(ignoreFilePath, defaultIgnorefile + "\n")
-        const gardenIgnoreRelPath = chalk.bold.white(relative(process.cwd(), ignoreFilePath))
+        const gardenIgnoreRelPath = styles.accent.bold(relative(process.cwd(), ignoreFilePath))
         log.info(
-          chalk.green(
+          styles.success(
             `-> Created default .gardenignore file at ${gardenIgnoreRelPath}. Please edit the .gardenignore file to add files or patterns that Garden should ignore when scanning and building.`
           )
         )
@@ -228,8 +230,8 @@ export class CreateProjectCommand extends Command<CreateProjectArgs, CreateProje
     log.info("")
 
     // This is to avoid `prettier` messing with the string formatting...
-    const configFilesUrl = chalk.cyan.underline(`${DOCS_BASE_URL}/using-garden/configuration-overview`)
-    const referenceUrl = chalk.cyan.underline(projectReferenceURL)
+    const configFilesUrl = styles.highlight.underline(`${DOCS_BASE_URL}/using-garden/configuration-overview`)
+    const referenceUrl = styles.highlight.underline(projectReferenceURL)
 
     log.info(
       wordWrap(
diff --git a/core/src/commands/custom.ts b/core/src/commands/custom.ts
index 5eb4149f1f..5dfcd73531 100644
--- a/core/src/commands/custom.ts
+++ b/core/src/commands/custom.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { execa } from "execa"
 import { apply as jsonMerge } from "json-merge-patch"
 import cloneDeep from "fast-copy"
@@ -32,6 +31,7 @@ import { join } from "path"
 import { getBuiltinCommands } from "./commands.js"
 import type { Log } from "../logger/log-entry.js"
 import { getTracePropagationEnvVars } from "../util/open-telemetry/propagation.js"
+import { styles } from "../logger/styles.js"
 
 function convertArgSpec(spec: CustomCommandOption) {
   const params = {
@@ -89,7 +89,7 @@ export class CustomCommandWrapper extends Command {
   }
 
   override printHeader({ log }: PrintHeaderParams) {
-    log.info(chalk.cyan(this.name))
+    log.info(styles.highlight(this.name))
   }
 
   async action({
@@ -272,7 +272,7 @@ export async function getCustomCommands(log: Log, projectRoot: string) {
       if (builtinNames.includes(r.name)) {
         // eslint-disable-next-line no-console
         console.log(
-          chalk.yellow(
+          styles.warning(
             `Ignoring custom command ${r.name} because it conflicts with a built-in command with the same name`
           )
         )
diff --git a/core/src/commands/delete.ts b/core/src/commands/delete.ts
index 86cde0a065..44442697e5 100644
--- a/core/src/commands/delete.ts
+++ b/core/src/commands/delete.ts
@@ -21,7 +21,6 @@ import { isDeployAction } from "../actions/deploy.js"
 import { omit, mapValues } from "lodash-es"
 import type { DeployStatus, DeployStatusMap } from "../plugin/handlers/Deploy/get-status.js"
 import { getDeployStatusSchema } from "../plugin/handlers/Deploy/get-status.js"
-import chalk from "chalk"
 
 // TODO: rename this to CleanupCommand, and do the same for all related classes, constants, variables and functions
 export class DeleteCommand extends CommandGroup {
@@ -101,7 +100,7 @@ export class DeleteEnvironmentCommand extends Command<{}, DeleteEnvironmentOpts>
 
     const providerStatuses = await actions.provider.cleanupAll(log)
 
-    log.info(chalk.green("\nDone!"))
+    log.success({ msg: "\nDone!", showDuration: false })
 
     return {
       result: {
@@ -207,7 +206,7 @@ export class DeleteDeployCommand extends Command<DeleteDeployArgs, DeleteDeployO
     const processed = await garden.processTasks({ tasks, log })
     const result = deletedDeployStatuses(processed.results)
 
-    log.info(chalk.green("\nDone!"))
+    log.success({ msg: "\nDone!", showDuration: false })
 
     return { result }
   }
diff --git a/core/src/commands/deploy.ts b/core/src/commands/deploy.ts
index 7029853d75..b40d083a29 100644
--- a/core/src/commands/deploy.ts
+++ b/core/src/commands/deploy.ts
@@ -8,7 +8,6 @@
 
 import deline from "deline"
 import dedent from "dedent"
-import chalk from "chalk"
 
 import type { CommandParams, CommandResult, PrepareParams, ProcessCommandResult } from "./base.js"
 import { Command, handleProcessResults, processCommandResultSchema, emptyActionResults } from "./base.js"
@@ -31,6 +30,7 @@ import { serveOpts } from "./serve.js"
 import { gardenEnv } from "../constants.js"
 import type { DeployAction } from "../actions/deploy.js"
 import { watchParameter, watchRemovedWarning } from "./util/watch-parameter.js"
+import { styles } from "../logger/styles.js"
 
 export const deployArgs = {
   names: new StringsParameter({
@@ -203,10 +203,10 @@ export class DeployCommand extends Command<Args, Opts> {
     const disabled = deployActions.filter((s) => s.isDisabled()).map((s) => s.name)
 
     if (disabled.length > 0) {
-      const bold = disabled.map((d) => chalk.white(d))
+      const bold = disabled.map((d) => styles.accent(d))
       const msg =
         disabled.length === 1 ? `Deploy action ${bold} is disabled` : `Deploy actions ${naturalList(bold)} are disabled`
-      log.info(chalk.gray(msg))
+      log.info(styles.primary(msg))
     }
 
     const skipRuntimeDependencies = opts["skip-dependencies"]
diff --git a/core/src/commands/dev.tsx b/core/src/commands/dev.tsx
index 86f3ee5780..16ad72aa55 100644
--- a/core/src/commands/dev.tsx
+++ b/core/src/commands/dev.tsx
@@ -15,7 +15,6 @@ import { LoggerType } from "../logger/logger.js"
 import { ParameterError, toGardenError } from "../exceptions.js"
 import { InkTerminalWriter } from "../logger/writers/ink-terminal-writer.js"
 import { CommandLine } from "../cli/command-line.js"
-import chalk from "chalk"
 import { globalOptions, StringsParameter } from "../cli/params.js"
 import { pick } from "lodash-es"
 import moment from "moment"
@@ -24,6 +23,7 @@ import Spinner from "ink-spinner"
 import type { Log } from "../logger/log-entry.js"
 import { bindActiveContext } from "../util/open-telemetry/context.js"
 import Divider from "../util/ink-divider.js"
+import { styles } from "../logger/styles.js"
 
 const devCommandArgs = {
   ...serveArgs,
@@ -58,17 +58,17 @@ export class DevCommand extends ServeCommand<DevCommandArgs, DevCommandOpts> {
     console.clear()
 
     log.info(
-      chalk.magenta(`
-${renderDivider({ color: chalk.green, title: chalk.green.bold("🌳  garden dev 🌳 "), width })}
+      styles.highlightSecondary(`
+${renderDivider({ color: styles.success, title: styles.success.bold("🌳  garden dev 🌳 "), width })}
 
-${chalk.bold(`Good ${getGreetingTime()}! Welcome to the Garden interactive development console.`)}
+${styles.bold(`Good ${getGreetingTime()}! Welcome to the Garden interactive development console.`)}
 
-Here, you can ${chalk.white("build")}, ${chalk.white("deploy")}, ${chalk.white("test")} and ${chalk.white(
+Here, you can ${styles.accent("build")}, ${styles.accent("deploy")}, ${styles.accent("test")} and ${styles.accent(
         "run"
       )} anything in your project, start code syncing, stream live logs and more.
 
-Use the command line below to enter Garden commands. Type ${chalk.white("help")} to get a full list of commands.
-Use ${chalk.bold("up/down")} arrow keys to scroll through your command history.
+Use the command line below to enter Garden commands. Type ${styles.accent("help")} to get a full list of commands.
+Use ${styles.bold("up/down")} arrow keys to scroll through your command history.
     `)
     )
   }
@@ -180,7 +180,7 @@ Use ${chalk.bold("up/down")} arrow keys to scroll through your command history.
       log.error(`Failed loading the project: ${error}`)
       log.error({ error: toGardenError(error) })
       this.commandLine?.flashError(
-        `Failed loading the project. See above logs for details. Type ${chalk.white("reload")} to try again.`
+        `Failed loading the project. See above logs for details. Type ${styles.accent("reload")} to try again.`
       )
     } finally {
       this.commandLine?.enable()
@@ -211,7 +211,7 @@ Use ${chalk.bold("up/down")} arrow keys to scroll through your command history.
       // We ensure that the process exits at most 5 seconds after a SIGINT / ctrl-c.
       setTimeout(() => {
         // eslint-disable-next-line no-console
-        console.error(chalk.red("\nTimed out waiting for Garden to exit. This is a bug, please report it!"))
+        console.error(styles.error("\nTimed out waiting for Garden to exit. This is a bug, please report it!"))
         process.exit(1)
       }, 5000)
 
@@ -219,13 +219,11 @@ Use ${chalk.bold("up/down")} arrow keys to scroll through your command history.
         .emitWarning({
           log,
           key: "dev-syncs-active",
-          message: chalk.yellow(
-            `Syncs started during this session may still be active when this command terminates. You can run ${chalk.white(
-              "garden sync stop '*'"
-            )} to stop all code syncs. Hint: To stop code syncing when exiting ${chalk.white(
-              "garden dev"
-            )}, use ${chalk.white("Ctrl-D")} or the ${chalk.white(`exit`)} command.`
-          ),
+          message: `Syncs started during this session may still be active when this command terminates. You can run ${styles.accent(
+            "garden sync stop '*'"
+          )} to stop all code syncs. Hint: To stop code syncing when exiting ${styles.accent(
+            "garden dev"
+          )}, use ${styles.accent("Ctrl-D")} or the ${styles.accent(`exit`)} command.`,
         })
         .catch(() => {})
         .finally(() => quit())
@@ -281,7 +279,7 @@ class QuietCommand extends ConsoleCommand {
   override hidden = true
 
   async action({ commandLine }: CommandParams) {
-    commandLine?.flashMessage(chalk.italic("Shh!"), { prefix: "🀫  " })
+    commandLine?.flashMessage(styles.italic("Shh!"), { prefix: "🀫  " })
     return {}
   }
 }
@@ -292,7 +290,7 @@ class QuiteCommand extends ConsoleCommand {
   override hidden = true
 
   async action({ commandLine }: CommandParams) {
-    commandLine?.flashMessage(chalk.italic("Indeed!"), { prefix: "🎩  " })
+    commandLine?.flashMessage(styles.italic("Indeed!"), { prefix: "🎩  " })
     return {}
   }
 }
diff --git a/core/src/commands/exec.ts b/core/src/commands/exec.ts
index d34a2f41a0..4c442fb28f 100644
--- a/core/src/commands/exec.ts
+++ b/core/src/commands/exec.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { printHeader } from "../logger/util.js"
 import type { CommandResult, CommandParams } from "./base.js"
 import { Command } from "./base.js"
@@ -20,6 +19,7 @@ import { NotFoundError } from "../exceptions.js"
 import type { DeployStatus } from "../plugin/handlers/Deploy/get-status.js"
 import { createActionLog } from "../logger/log-entry.js"
 import { K8_POD_DEFAULT_CONTAINER_ANNOTATION_KEY } from "../plugins/kubernetes/run.js"
+import { styles } from "../logger/styles.js"
 
 const execArgs = {
   deploy: new StringParameter({
@@ -78,7 +78,11 @@ export class ExecCommand extends Command<Args, Opts> {
   override printHeader({ log, args }) {
     const deployName = args.deploy
     const command = this.getCommand(args)
-    printHeader(log, `Running command ${chalk.cyan(command.join(" "))} in Deploy ${chalk.cyan(deployName)}`, "runner")
+    printHeader(
+      log,
+      `Running command ${styles.highlight(command.join(" "))} in Deploy ${styles.highlight(deployName)}`,
+      "runner"
+    )
   }
 
   async action({ garden, log, args, opts }: CommandParams<Args, Opts>): Promise<CommandResult<ExecInDeployResult>> {
@@ -104,9 +108,9 @@ export class ExecCommand extends Command<Args, Opts> {
       case "unhealthy":
       case "unknown":
         log.warn(
-          `The current state of ${action.key()} is ${chalk.whiteBright(
-            deployState
-          )}. If this command fails, you may need to re-deploy it with the ${chalk.whiteBright("deploy")} command.`
+          `The current state of ${action.key()} is ${styles.accent(
+            deployState || "unknown"
+          )}. If this command fails, you may need to re-deploy it with the ${styles.accent("deploy")} command.`
         )
         break
       case "outdated":
@@ -123,9 +127,9 @@ export class ExecCommand extends Command<Args, Opts> {
         // if there is an active sync, the state is likely to be outdated so do not display this warning
         if (!(deploySync?.syncCount && deploySync?.syncCount > 0 && deploySync?.state === "active")) {
           log.warn(
-            `The current state of ${action.key()} is ${chalk.whiteBright(
+            `The current state of ${action.key()} is ${styles.accent(
               deployState
-            )}. If this command fails, you may need to re-deploy it with the ${chalk.whiteBright("deploy")} command.`
+            )}. If this command fails, you may need to re-deploy it with the ${styles.accent("deploy")} command.`
           )
         }
         break
diff --git a/core/src/commands/get/get-actions.ts b/core/src/commands/get/get-actions.ts
index 4361ca5ee6..09655e39f8 100644
--- a/core/src/commands/get/get-actions.ts
+++ b/core/src/commands/get/get-actions.ts
@@ -6,13 +6,13 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { getActionState, getRelativeActionConfigPath } from "../../actions/helpers.js"
 import type { ActionKind, ActionState, ResolvedAction } from "../../actions/types.js"
 import { actionKinds, actionStateTypes } from "../../actions/types.js"
 import { BooleanParameter, ChoicesParameter, StringsParameter } from "../../cli/params.js"
 import { createSchema, joi, joiArray } from "../../config/common.js"
 import { printHeader } from "../../logger/util.js"
+import { styles } from "../../logger/styles.js"
 import { dedent, deline, renderTable } from "../../util/string.js"
 import type { CommandParams, CommandResult } from "../base.js"
 import { Command } from "../base.js"
@@ -222,7 +222,7 @@ export class GetActionsCommand extends Command {
 
     let rows: string[][] = []
     rows = getActionsOutput.map((a) => {
-      let r = [chalk.cyan.bold(a.name), a.kind, a.type]
+      let r = [styles.highlight.bold(a.name), a.kind, a.type]
       if (includeStateInOutput) {
         r.push(a.state ?? "unknown")
       }
@@ -244,7 +244,7 @@ export class GetActionsCommand extends Command {
       cols = cols.concat(["Path", "Dependencies", "Dependents", "Disabled", ...(showModuleCol ? ["Module"] : [])])
     }
 
-    const heading = cols.map((s) => chalk.bold(s))
+    const heading = cols.map((s) => styles.bold(s))
 
     if (getActionsOutput.length > 0) {
       log.info("")
diff --git a/core/src/commands/get/get-debug-info.ts b/core/src/commands/get/get-debug-info.ts
index 4ecdc40204..d168e5b594 100644
--- a/core/src/commands/get/get-debug-info.ts
+++ b/core/src/commands/get/get-debug-info.ts
@@ -20,13 +20,13 @@ import { ERROR_LOG_FILENAME } from "../../constants.js"
 import dedent from "dedent"
 import type { Garden } from "../../garden.js"
 import { zipFolder } from "../../util/archive.js"
-import chalk from "chalk"
 import { GitHandler } from "../../vcs/git.js"
 import { ValidationError } from "../../exceptions.js"
 import { ChoicesParameter, BooleanParameter } from "../../cli/params.js"
 import { printHeader } from "../../logger/util.js"
 import { TreeCache } from "../../cache.js"
 import { safeDumpYaml } from "../../util/serialization.js"
+import { styles } from "../../logger/styles.js"
 
 export const TEMP_DEBUG_ROOT = "tmp"
 export const SYSTEM_INFO_FILENAME_NO_EXT = "system-info"
@@ -176,7 +176,7 @@ export async function collectProviderDebugInfo(garden: Garden, log: Log, format:
  * @param {Log} log
  */
 export async function generateBasicDebugInfoReport(root: string, gardenDirPath: string, log: Log, format = "json") {
-  log.warn(chalk.yellow("It looks like Garden couldn't validate your project: generating basic report."))
+  log.warn("It looks like Garden couldn't validate your project: generating basic report.")
 
   const tempPath = join(gardenDirPath, TEMP_DEBUG_ROOT)
   log.info({ msg: "Collecting basic debug info" })
@@ -293,7 +293,7 @@ export class GetDebugInfoCommand extends Command<Args, Opts> {
     } catch (err) {
       // One or multiple providers threw an error while processing.
       // Skip the step but still create a report.
-      providerLog.warn(chalk.yellow(`Failed to collect providers info. Skipping this step.`))
+      providerLog.warn(`Failed to collect providers info. Skipping this step.`)
     }
 
     // Zip report folder
@@ -307,15 +307,18 @@ export class GetDebugInfoCommand extends Command<Args, Opts> {
 
     log.success("Done")
 
-    log.info(chalk.green(`\nDone! Please find your report at  ${outputFilePath}.\n`))
+    log.success({
+      msg: styles.success(`\nDone! Please find your report at  ${outputFilePath}.\n`),
+      showDuration: false,
+    })
 
     log.warn(
-      chalk.yellow(dedent`
+      dedent`
         NOTE: Please be aware that the output file might contain sensitive information.
         If you plan to make the file available to the general public (e.g. GitHub), please review the content first.
         If you need to share a file containing sensitive information with the Garden team, please contact us on
         our Discord community: https://discord.gg/FrmhuUjFs6.
-      `)
+      `
     )
 
     return { result: 0 }
diff --git a/core/src/commands/get/get-files.ts b/core/src/commands/get/get-files.ts
index 294a657561..665b451e60 100644
--- a/core/src/commands/get/get-files.ts
+++ b/core/src/commands/get/get-files.ts
@@ -12,7 +12,7 @@ import { joi } from "../../config/common.js"
 import { printHeader } from "../../logger/util.js"
 import type { CommandParams, CommandResult } from "../base.js"
 import { Command } from "../base.js"
-import chalk from "chalk"
+import { styles } from "../../logger/styles.js"
 
 const getFilesArgs = {
   keys: new StringsParameter({
@@ -52,7 +52,7 @@ export class GetFilesCommand extends Command<Args, Opts> {
         const files = a.getFullVersion().files
 
         log.info("")
-        log.info(chalk.cyanBright(key))
+        log.info(styles.highlight(key))
         log.info(files.length ? files.map((f) => "- " + f).join("\n") : "(none)")
 
         return [key, files]
diff --git a/core/src/commands/get/get-linked-repos.ts b/core/src/commands/get/get-linked-repos.ts
index 29e73ad715..de8cf2420c 100644
--- a/core/src/commands/get/get-linked-repos.ts
+++ b/core/src/commands/get/get-linked-repos.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { sortBy } from "lodash-es"
 import type { CommandParams, CommandResult } from "../base.js"
 import { Command } from "../base.js"
@@ -14,6 +13,7 @@ import type { LinkedSource } from "../../config-store/local.js"
 import { printHeader } from "../../logger/util.js"
 import { getLinkedSources } from "../../util/ext-source-util.js"
 import { renderTable } from "../../util/string.js"
+import { styles } from "../../logger/styles.js"
 
 const getLinkedReposArguments = {}
 
@@ -37,7 +37,7 @@ export class GetLinkedReposCommand extends Command {
     log.info("")
 
     if (linkedSources.length === 0) {
-      log.info(chalk.white("No linked sources, actions or modules found for this project."))
+      log.info(styles.accent("No linked sources, actions or modules found for this project."))
     } else {
       const linkedSourcesWithType = [
         ...linkedProjectSources.map((s) => ({ ...s, type: "source" })),
@@ -46,8 +46,12 @@ export class GetLinkedReposCommand extends Command {
       ]
 
       const rows = [
-        [chalk.bold("Name:"), chalk.bold("Type:"), chalk.bold("Path:")],
-        ...linkedSourcesWithType.map((s) => [chalk.cyan.bold(s.name), chalk.cyan.bold(s.type), s.path.trim()]),
+        [styles.bold("Name:"), styles.bold("Type:"), styles.bold("Path:")],
+        ...linkedSourcesWithType.map((s) => [
+          styles.highlight.bold(s.name),
+          styles.highlight.bold(s.type),
+          s.path.trim(),
+        ]),
       ]
 
       log.info(renderTable(rows))
diff --git a/core/src/commands/get/get-modules.ts b/core/src/commands/get/get-modules.ts
index 472bd68fce..48f98073ec 100644
--- a/core/src/commands/get/get-modules.ts
+++ b/core/src/commands/get/get-modules.ts
@@ -16,13 +16,13 @@ import type { StringMap } from "../../config/common.js"
 import { joiIdentifierMap, createSchema } from "../../config/common.js"
 import { printEmoji, printHeader, renderDivider } from "../../logger/util.js"
 import { withoutInternalFields } from "../../util/logging.js"
-import chalk from "chalk"
 import { renderTable, dedent, deline } from "../../util/string.js"
 import { relative, sep } from "path"
 import type { Garden } from "../../index.js"
 import type { Log } from "../../logger/log-entry.js"
 import { highlightYaml, safeDumpYaml } from "../../util/serialization.js"
 import { deepMap } from "../../util/objects.js"
+import { styles } from "../../logger/styles.js"
 
 const getModulesArgs = {
   modules: new StringsParameter({
@@ -99,7 +99,7 @@ export class GetModulesCommand extends Command {
 }
 
 function logFull(garden: Garden, modules: GardenModule[], log: Log) {
-  const divider = chalk.gray(renderDivider())
+  const divider = styles.primary(renderDivider())
   log.info("")
   for (const module of modules) {
     const version = module.version.versionString
@@ -128,7 +128,7 @@ function logFull(garden: Garden, modules: GardenModule[], log: Log) {
     const yaml = safeDumpYaml(rendered, { noRefs: true, sortKeys: true })
     log.info(dedent`
       ${divider}
-      ${printEmoji("🌱", log)}  Module: ${chalk.green(module.name)}
+      ${printEmoji("🌱", log)}  Module: ${styles.success(module.name)}
       ${divider}\n
     `)
     log.info(highlightYaml(yaml))
@@ -136,9 +136,9 @@ function logFull(garden: Garden, modules: GardenModule[], log: Log) {
 }
 
 function logAsTable(garden: Garden, modules: GardenModule[], log: Log) {
-  const heading = ["Name", "Version", "Type", "Path"].map((s) => chalk.bold(s))
+  const heading = ["Name", "Version", "Type", "Path"].map((s) => styles.bold(s))
   const rows: string[][] = modules.map((m) => [
-    chalk.cyan.bold(m.name),
+    styles.highlight.bold(m.name),
     m.version.versionString,
     m.type,
     getRelativeModulePath(garden.projectRoot, m.path),
diff --git a/core/src/commands/get/get-outputs.ts b/core/src/commands/get/get-outputs.ts
index 6b815013b8..1d8704bac8 100644
--- a/core/src/commands/get/get-outputs.ts
+++ b/core/src/commands/get/get-outputs.ts
@@ -13,8 +13,8 @@ import { fromPairs } from "lodash-es"
 import type { PrimitiveMap } from "../../config/common.js"
 import { joiVariables } from "../../config/common.js"
 import { renderTable, dedent } from "../../util/string.js"
-import chalk from "chalk"
 import { resolveProjectOutputs } from "../../outputs.js"
+import { styles } from "../../logger/styles.js"
 
 export class GetOutputsCommand extends Command {
   name = "outputs"
@@ -41,11 +41,11 @@ export class GetOutputsCommand extends Command {
     const outputs = await resolveProjectOutputs(garden, log)
 
     const rows = [
-      { [chalk.bold("Name:")]: [chalk.bold("Value:")] },
-      ...outputs.map((o) => ({ [chalk.cyan.bold(o.name)]: [o.value?.toString().trim()] })),
+      { [styles.bold("Name:")]: [styles.bold("Value:")] },
+      ...outputs.map((o) => ({ [styles.highlight.bold(o.name)]: [o.value?.toString().trim()] })),
     ]
     log.info("")
-    log.info(chalk.white.bold("Outputs:"))
+    log.info(styles.accent.bold("Outputs:"))
     log.info(renderTable(rows))
 
     return { result: fromPairs(outputs.map((o) => [o.name, o.value])) }
diff --git a/core/src/commands/get/get-run-result.ts b/core/src/commands/get/get-run-result.ts
index bf230aedad..a159cf4bd6 100644
--- a/core/src/commands/get/get-run-result.ts
+++ b/core/src/commands/get/get-run-result.ts
@@ -10,13 +10,13 @@ import type { ConfigGraph } from "../../graph/config-graph.js"
 import type { CommandParams } from "../base.js"
 import { Command } from "../base.js"
 import { printHeader } from "../../logger/util.js"
-import chalk from "chalk"
 import { getArtifactFileList, getArtifactKey } from "../../util/artifacts.js"
 import { joiArray, joi } from "../../config/common.js"
 import { StringParameter } from "../../cli/params.js"
 import type { GetRunResult } from "../../plugin/handlers/Run/get-result.js"
 import { getRunResultSchema } from "../../plugin/handlers/Run/get-result.js"
 import { createActionLog } from "../../logger/log-entry.js"
+import { styles } from "../../logger/styles.js"
 
 const getRunResultArgs = {
   name: new StringParameter({
@@ -56,7 +56,7 @@ export class GetRunResultCommand extends Command<Args, {}, GetRunResultCommandRe
 
   override printHeader({ log, args }) {
     const taskName = args.name
-    printHeader(log, `Run result for ${chalk.cyan(taskName)}`, "πŸš€")
+    printHeader(log, `Run result for ${styles.highlight(taskName)}`, "πŸš€")
   }
 
   async action({ garden, log, args }: CommandParams<Args>) {
diff --git a/core/src/commands/get/get-status.ts b/core/src/commands/get/get-status.ts
index bb2b120daf..bb4a3ef7cc 100644
--- a/core/src/commands/get/get-status.ts
+++ b/core/src/commands/get/get-status.ts
@@ -13,7 +13,6 @@ import { Command } from "../base.js"
 import type { ResolvedConfigGraph } from "../../graph/config-graph.js"
 import type { Log } from "../../logger/log-entry.js"
 import { createActionLog } from "../../logger/log-entry.js"
-import chalk from "chalk"
 import { deline } from "../../util/string.js"
 import type { EnvironmentStatusMap } from "../../plugin/handlers/Provider/getEnvironmentStatus.js"
 import { joi, joiIdentifierMap, joiStringMap } from "../../config/common.js"
@@ -30,6 +29,7 @@ import { getDeployStatusSchema } from "../../plugin/handlers/Deploy/get-status.j
 import type { ActionRouter } from "../../router/router.js"
 import { sanitizeValue } from "../../util/logging.js"
 import { BooleanParameter } from "../../cli/params.js"
+import { styles } from "../../logger/styles.js"
 
 // Value is "completed" if the test/task has been run for the current version.
 export interface StatusCommandResult {
@@ -123,13 +123,11 @@ export class GetStatusCommand extends Command {
     for (const [name, status] of Object.entries(finalDeployStatuses)) {
       if (status.state === "unknown") {
         log.warn(
-          chalk.yellow(
-            deline`
-            Unable to resolve status for Deploy ${chalk.white(name)}. It is likely missing or outdated.
+          deline`
+            Unable to resolve status for Deploy ${styles.accent(name)}. It is likely missing or outdated.
             This can come up if the deployment has runtime dependencies that are not resolvable, i.e. not deployed or
             invalid.
             `
-          )
         )
       }
     }
diff --git a/core/src/commands/get/get-test-result.ts b/core/src/commands/get/get-test-result.ts
index 5b082775d0..9e7f1f5897 100644
--- a/core/src/commands/get/get-test-result.ts
+++ b/core/src/commands/get/get-test-result.ts
@@ -9,7 +9,6 @@
 import type { CommandParams } from "../base.js"
 import { Command } from "../base.js"
 import { printHeader } from "../../logger/util.js"
-import chalk from "chalk"
 import { getArtifactFileList, getArtifactKey } from "../../util/artifacts.js"
 import { joi, joiArray } from "../../config/common.js"
 import type { GetTestResult } from "../../plugin/handlers/Test/get-result.js"
@@ -24,6 +23,7 @@ import { findByName, getNames } from "../../util/util.js"
 import { createActionLog } from "../../logger/log-entry.js"
 import dedent from "dedent"
 import { naturalList } from "../../util/string.js"
+import { styles } from "../../logger/styles.js"
 
 const getTestResultArgs = {
   name: new StringParameter({
@@ -69,7 +69,11 @@ export class GetTestResultCommand extends Command<Args, {}, GetTestResultCommand
     const testName = args.name
     const moduleName = args.module
 
-    printHeader(log, `Test result for test ${chalk.cyan(testName)} in module ${chalk.cyan(moduleName)}`, "βœ”οΈ")
+    printHeader(
+      log,
+      `Test result for test ${styles.highlight(testName)} in module ${styles.highlight(moduleName)}`,
+      "βœ”οΈ"
+    )
   }
 
   async action({ garden, log, args }: CommandParams<Args>) {
diff --git a/core/src/commands/helpers.ts b/core/src/commands/helpers.ts
index f2210c311d..8994cda206 100644
--- a/core/src/commands/helpers.ts
+++ b/core/src/commands/helpers.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import indentString from "indent-string"
 
 import type { WorkflowConfig } from "../config/workflow.js"
@@ -18,6 +17,7 @@ import { naturalList } from "../util/string.js"
 import type { CommandParams } from "./base.js"
 import type { ServeCommandOpts } from "./serve.js"
 import { DevCommand } from "./dev.js"
+import { styles } from "../logger/styles.js"
 
 /**
  * Runs a `dev` command and runs `commandName` with the args & opts provided in `params` as the first
@@ -46,7 +46,7 @@ export function getCmdOptionForDev(commandName: string, params: CommandParams) {
 }
 
 export function prettyPrintWorkflow(workflow: WorkflowConfig): string {
-  let out = `${chalk.cyan.bold(workflow.name)}`
+  let out = `${styles.highlight.bold(workflow.name)}`
 
   if (workflow.description) {
     out += "\n" + indentString(printField("description", workflow.description), 2)
@@ -58,7 +58,7 @@ export function prettyPrintWorkflow(workflow: WorkflowConfig): string {
 }
 
 function printField(name: string, value: string | null) {
-  return `${chalk.gray(name)}: ${value || ""}`
+  return `${styles.primary(name)}: ${value || ""}`
 }
 
 /**
diff --git a/core/src/commands/link/action.ts b/core/src/commands/link/action.ts
index b127029bea..e8d1b2e046 100644
--- a/core/src/commands/link/action.ts
+++ b/core/src/commands/link/action.ts
@@ -8,7 +8,6 @@
 
 import { resolve } from "path"
 import dedent from "dedent"
-import chalk from "chalk"
 
 import { ParameterError } from "../../exceptions.js"
 import type { CommandResult, CommandParams } from "../base.js"
@@ -20,6 +19,7 @@ import { joiArray, joi } from "../../config/common.js"
 import { StringParameter, PathParameter } from "../../cli/params.js"
 import { linkedActionSchema } from "../../config/project.js"
 import { actionKinds } from "../../actions/types.js"
+import { styles } from "../../logger/styles.js"
 
 const linkActionArguments = {
   action: new StringParameter({
@@ -74,7 +74,7 @@ export class LinkActionCommand extends Command<Args> {
     if (!action.hasRemoteSource()) {
       throw new ParameterError({
         message: dedent`
-          Expected action ${chalk.underline(key)} to have a remote source.
+          Expected action ${styles.underline(key)} to have a remote source.
           Did you mean to use the "link source" command?
         `,
       })
diff --git a/core/src/commands/link/module.ts b/core/src/commands/link/module.ts
index a2e569209c..833de1fd17 100644
--- a/core/src/commands/link/module.ts
+++ b/core/src/commands/link/module.ts
@@ -8,7 +8,6 @@
 
 import { resolve } from "path"
 import dedent from "dedent"
-import chalk from "chalk"
 
 import { ParameterError } from "../../exceptions.js"
 import type { CommandResult, CommandParams } from "../base.js"
@@ -20,6 +19,7 @@ import { joiArray, joi } from "../../config/common.js"
 import { linkedModuleSchema } from "../../config/project.js"
 import { StringParameter, PathParameter } from "../../cli/params.js"
 import { naturalList } from "../../util/string.js"
+import { styles } from "../../logger/styles.js"
 
 const linkModuleArguments = {
   module: new StringParameter({
@@ -78,7 +78,7 @@ export class LinkModuleCommand extends Command<Args> {
 
       throw new ParameterError({
         message: dedent`
-          Expected module(s) ${chalk.underline(
+          Expected module(s) ${styles.underline(
             moduleName
           )} to have a remote source. Did you mean to use the "link source" command? ${
             modulesWithRemoteSource.length > 0
diff --git a/core/src/commands/link/source.ts b/core/src/commands/link/source.ts
index 8f06ae92ff..639f348d01 100644
--- a/core/src/commands/link/source.ts
+++ b/core/src/commands/link/source.ts
@@ -8,7 +8,6 @@
 
 import { resolve } from "path"
 import dedent from "dedent"
-import chalk from "chalk"
 
 import { ParameterError } from "../../exceptions.js"
 import type { CommandResult } from "../base.js"
@@ -21,6 +20,7 @@ import { joiArray, joi } from "../../config/common.js"
 import { linkedSourceSchema } from "../../config/project.js"
 import { StringParameter, PathParameter } from "../../cli/params.js"
 import { naturalList } from "../../util/string.js"
+import { styles } from "../../logger/styles.js"
 
 const linkSourceArguments = {
   source: new StringParameter({
@@ -78,7 +78,7 @@ export class LinkSourceCommand extends Command<Args> {
 
       throw new ParameterError({
         message: dedent`
-          Remote source ${chalk.underline(
+          Remote source ${styles.underline(
             sourceName
           )} not found in project config. Did you mean to use the "link module" command?${
             availableRemoteSources.length > 0
diff --git a/core/src/commands/logs.ts b/core/src/commands/logs.ts
index d4bddf8c42..f8e2637729 100644
--- a/core/src/commands/logs.ts
+++ b/core/src/commands/logs.ts
@@ -9,7 +9,6 @@
 import dotenv from "dotenv"
 import type { CommandResult, CommandParams, PrepareParams } from "./base.js"
 import { Command } from "./base.js"
-import chalk from "chalk"
 import { omit, sortBy } from "lodash-es"
 import type { DeployLogEntry } from "../types/service.js"
 import { LogLevel, parseLogLevel, VoidLogger } from "../logger/logger.js"
@@ -19,6 +18,7 @@ import { dedent, deline, naturalList } from "../util/string.js"
 import { CommandError, ParameterError } from "../exceptions.js"
 import type { LogsTagOrFilter } from "../monitors/logs.js"
 import { LogMonitor } from "../monitors/logs.js"
+import { styles } from "../logger/styles.js"
 
 const logsArgs = {
   names: new StringsParameter({
@@ -175,8 +175,8 @@ export class LogsCommand extends Command<Args, Opts> {
     }
 
     log.info("")
-    log.info(chalk.white.bold("Service logs" + details + ":"))
-    log.info(chalk.white.bold(renderDivider()))
+    log.info(styles.accent.bold("Service logs" + details + ":"))
+    log.info(styles.accent.bold(renderDivider()))
 
     const resolvedActions = await garden.resolveActions({ actions, graph, log })
 
@@ -217,7 +217,7 @@ export class LogsCommand extends Command<Args, Opts> {
         entry.monitor.logEntry(entry)
       })
 
-      log.info(chalk.white.bold(renderDivider()))
+      log.info(styles.accent.bold(renderDivider()))
 
       return {
         result: sorted.map((e) => omit(e, "monitor")),
diff --git a/core/src/commands/plugins.ts b/core/src/commands/plugins.ts
index 23ea56e717..0f9c3f5c7a 100644
--- a/core/src/commands/plugins.ts
+++ b/core/src/commands/plugins.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { max, fromPairs, zip } from "lodash-es"
 import { findByName, getNames } from "../util/util.js"
 import { dedent, naturalList, renderTable, tablePresets } from "../util/string.js"
@@ -19,6 +18,7 @@ import { printHeader, getTerminalWidth } from "../logger/util.js"
 import { StringOption } from "../cli/params.js"
 import { ConfigGraph } from "../graph/config-graph.js"
 import { ModuleGraph } from "../graph/modules.js"
+import { styles } from "../logger/styles.js"
 
 const pluginArgs = {
   plugin: new StringOption({
@@ -126,11 +126,11 @@ export class PluginsCommand extends Command<Args> {
 
 async function listPlugins(garden: Garden, log: Log, pluginsToList: string[]) {
   log.info(dedent`
-  ${chalk.white.bold("USAGE")}
+  ${styles.accent.bold("USAGE")}
 
-    garden ${chalk.yellow("[global options]")} ${chalk.blueBright("<command>")} -- ${chalk.white("[args ...]")}
+    garden ${styles.warning("[global options]")} ${styles.command("<command>")} -- ${styles.accent("[args ...]")}
 
-  ${chalk.white.bold("PLUGIN COMMANDS")}
+  ${styles.accent.bold("PLUGIN COMMANDS")}
   `)
 
   const plugins = await Promise.all(
@@ -143,7 +143,7 @@ async function listPlugins(garden: Garden, log: Log, pluginsToList: string[]) {
       }
 
       const rows = commands.map((command) => {
-        return [` ${chalk.cyan(pluginName + " " + command.name)}`, command.description]
+        return [` ${styles.highlight(pluginName + " " + command.name)}`, command.description]
       })
 
       const maxCommandLengthAnsi = max(rows.map((r) => r[0].length))!
diff --git a/core/src/commands/run.ts b/core/src/commands/run.ts
index 66b0833caa..0a6e9a52d8 100644
--- a/core/src/commands/run.ts
+++ b/core/src/commands/run.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type { CommandParams, CommandResult, PrepareParams, ProcessCommandResult } from "./base.js"
 import { Command, handleProcessResults, processCommandResultSchema } from "./base.js"
 import { RunTask } from "../tasks/run.js"
@@ -20,6 +19,7 @@ import { TestCommand } from "./test.js"
 import type { WorkflowRunOutput } from "./workflow.js"
 import { WorkflowCommand } from "./workflow.js"
 import { watchParameter, watchRemovedWarning } from "./util/watch-parameter.js"
+import { styles } from "../logger/styles.js"
 
 // TODO: support interactive execution for a single Run (needs implementation from RunTask through plugin handlers).
 
@@ -174,11 +174,11 @@ export class RunCommand extends Command<Args, Opts> {
     for (const action of actions) {
       if (action.isDisabled() && !opts.force) {
         log.warn(
-          chalk.yellow(deline`
-            ${chalk.redBright(action.longDescription())} is disabled for the ${chalk.redBright(garden.environmentName)}
+          deline`
+            ${styles.error(action.longDescription())} is disabled for the ${styles.error(garden.environmentName)}
             environment. If you're sure you want to run it anyway, please run the command again with the
-            ${chalk.redBright("--force")} flag.
-          `)
+            ${styles.error("--force")} flag.
+          `
         )
       }
     }
@@ -216,13 +216,13 @@ function maybeOldRunCommand(names: string[], args: any, opts: any, log: Log, par
   if (["module", "service", "task", "test", "workflow"].includes(firstArg)) {
     if (firstArg === "module" || firstArg === "service") {
       throw new ParameterError({
-        message: `Error: The ${chalk.white("garden run " + firstArg)} command has been removed.
+        message: `Error: The ${styles.accent("garden run " + firstArg)} command has been removed.
       Please define a Run action instead, or use the underlying tools (e.g. Docker or Kubernetes) directly.`,
       })
     }
     if (firstArg === "task") {
       log.warn(
-        `The ${chalk.yellow("run task")} command will be removed in Garden 0.14. Please use the ${chalk.yellow(
+        `The ${styles.command("run task")} command will be removed in Garden 0.14. Please use the ${styles.command(
           "run"
         )} command instead.`
       )
@@ -232,7 +232,7 @@ function maybeOldRunCommand(names: string[], args: any, opts: any, log: Log, par
     }
     if (firstArg === "test") {
       log.warn(
-        `The ${chalk.yellow("run test")} command will be removed in Garden 0.14. Please use the ${chalk.yellow(
+        `The ${styles.command("run test")} command will be removed in Garden 0.14. Please use the ${styles.command(
           "test"
         )} command instead.`
       )
@@ -243,7 +243,7 @@ function maybeOldRunCommand(names: string[], args: any, opts: any, log: Log, par
     }
     if (firstArg === "workflow") {
       log.warn(
-        `The ${chalk.yellow("run workflow")} command will be removed in Garden 0.14. Please use the ${chalk.yellow(
+        `The ${styles.command("run workflow")} command will be removed in Garden 0.14. Please use the ${styles.command(
           "workflow"
         )} command instead.`
       )
diff --git a/core/src/commands/self-update.ts b/core/src/commands/self-update.ts
index 1f8a1a3627..8df629254c 100644
--- a/core/src/commands/self-update.ts
+++ b/core/src/commands/self-update.ts
@@ -14,7 +14,6 @@ import type { GlobalOptions, ParameterValues } from "../cli/params.js"
 import { BooleanParameter, ChoicesParameter, StringParameter } from "../cli/params.js"
 import { dedent } from "../util/string.js"
 import { basename, dirname, join, resolve } from "path"
-import chalk from "chalk"
 import type { Architecture } from "../util/util.js"
 import { getArchitecture, isDarwinARM, getPackageVersion, getPlatform } from "../util/util.js"
 import { RuntimeError } from "../exceptions.js"
@@ -28,6 +27,7 @@ import semver from "semver"
 import type { Log } from "../logger/log-entry.js"
 import { realpath } from "fs/promises"
 import { pipeline } from "node:stream/promises"
+import { styles } from "../logger/styles.js"
 
 const ARM64_INTRODUCTION_VERSION = "0.13.12"
 
@@ -205,11 +205,11 @@ export async function getLatestVersions(numOfStableVersions: number, log: Log) {
   }
 
   return [
-    chalk.cyan("edge-acorn"),
-    chalk.cyan("edge-bonsai"),
+    styles.highlight("edge-acorn"),
+    styles.highlight("edge-bonsai"),
     ...releasesResponse
       .filter((r: any) => !r.prerelease && !r.draft)
-      .map((r: any) => chalk.cyan(r.name))
+      .map((r: any) => styles.highlight(r.name))
       .slice(0, numOfStableVersions),
   ]
 }
@@ -270,17 +270,17 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
     if (!installationDirectory) {
       installationDirectory = dirname(processExecPath)
       log.info(
-        chalk.white(
+        styles.accent(
           "No installation directory specified via --install-dir option. Garden will be re-installed to the current installation directory: "
-        ) + chalk.cyan(installationDirectory)
+        ) + styles.highlight(installationDirectory)
       )
     } else {
-      log.info(chalk.white("Installation directory: ") + chalk.cyan(installationDirectory))
+      log.info(styles.accent("Installation directory: ") + styles.highlight(installationDirectory))
     }
 
     installationDirectory = resolve(installationDirectory)
 
-    log.info(chalk.white("Checking for target and latest versions..."))
+    log.info(styles.accent("Checking for target and latest versions..."))
     const latestVersion = await getLatestVersion(log)
 
     if (!desiredVersion) {
@@ -288,14 +288,14 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
       desiredVersion = await this.findTargetVersion(currentVersion, versionScope, latestVersion)
     }
 
-    log.info(chalk.white("Current Garden version: ") + chalk.cyan(currentVersion))
-    log.info(chalk.white("Target Garden version to be installed: ") + chalk.cyan(desiredVersion))
-    log.info(chalk.white("Latest release version: ") + chalk.cyan(latestVersion))
+    log.info(styles.accent("Current Garden version: ") + styles.highlight(currentVersion))
+    log.info(styles.accent("Target Garden version to be installed: ") + styles.highlight(desiredVersion))
+    log.info(styles.accent("Latest release version: ") + styles.highlight(latestVersion))
 
     if (!opts.force && !opts["install-dir"] && desiredVersion === currentVersion) {
       log.warn("")
       log.warn(
-        chalk.yellow(
+        styles.warning(
           "The desired version and the current version are the same. Nothing to do. Specify --force if you'd like to re-install the same version."
         )
       )
@@ -315,9 +315,7 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
     const expectedExecutableName = process.platform === "win32" ? "garden.exe" : "garden"
     if (!opts["install-dir"] && basename(processExecPath) !== expectedExecutableName) {
       log.error(
-        chalk.redBright(
-          `The executable path ${processExecPath} doesn't indicate this is a normal binary installation for your platform. Perhaps you're running a local development build?`
-        )
+        `The executable path ${processExecPath} doesn't indicate this is a normal binary installation for your platform. Perhaps you're running a local development build?`
       )
       return {
         result: {
@@ -359,8 +357,8 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
       ) {
         if (platform === "macos") {
           architecture = "amd64"
-          log.info(
-            chalk.yellow.bold(
+          log.warn(
+            styles.bold(
               `No arm64 build available for Garden version ${desiredVersion}. Falling back to amd64 using Rosetta.`
             )
           )
@@ -381,7 +379,9 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
       const { build, filename, extension, url } = this.getReleaseArtifactDetails(platform, architecture, desiredVersion)
 
       log.info("")
-      log.info(chalk.white(`Downloading version ${chalk.cyan(desiredVersion)} from ${chalk.underline(url)}...`))
+      log.info(
+        styles.accent(`Downloading version ${styles.highlight(desiredVersion)} from ${styles.underline(url)}...`)
+      )
 
       const tempPath = join(tempDir.path, filename)
 
@@ -394,14 +394,14 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
           err.response?.statusCode === 404
         ) {
           log.info("")
-          log.error(chalk.redBright(`Could not find version ${desiredVersion} for ${build}.`))
+          log.error(styles.error(`Could not find version ${desiredVersion} for ${build}.`))
 
           // Print the latest available stable versions
           try {
             const latestVersions = await getLatestVersions(10, log)
 
             log.info(
-              chalk.white.bold(`Here are the latest available versions: `) + latestVersions.join(chalk.white(", "))
+              styles.accent.bold(`Here are the latest available versions: `) + latestVersions.join(styles.accent(", "))
             )
           } catch (err) {
             log.debug(`Could not retrieve the latest available versions, ${err}`)
@@ -428,7 +428,7 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
       await remove(backupPath)
       await mkdirp(backupPath)
 
-      log.info(chalk.white(`Backing up prior installation to ${chalk.gray(backupPath)}...`))
+      log.info(styles.accent(`Backing up prior installation to ${styles.primary(backupPath)}...`))
 
       for (const path of await readdir(installationDirectory)) {
         if (path === ".backup") {
@@ -439,7 +439,7 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
       }
 
       // Move the extracted files to the install directory
-      log.info(chalk.white(`Extracting to installation directory ${chalk.cyan(installationDirectory)}...`))
+      log.info(styles.accent(`Extracting to installation directory ${styles.highlight(installationDirectory)}...`))
 
       if (extension === "zip") {
         // Note: lazy-loading for startup performance
@@ -460,7 +460,7 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
       await copy(join(tempDir.path, build), installationDirectory)
 
       log.info("")
-      log.info(chalk.green("Done!"))
+      log.success({ msg: "Done!", showDuration: false })
 
       return {
         result: {
diff --git a/core/src/commands/serve.ts b/core/src/commands/serve.ts
index 501496677f..f65f75e38e 100644
--- a/core/src/commands/serve.ts
+++ b/core/src/commands/serve.ts
@@ -14,7 +14,6 @@ import { printEmoji, printHeader } from "../logger/util.js"
 import { dedent } from "../util/string.js"
 import type { CommandLine } from "../cli/command-line.js"
 import { GardenInstanceManager } from "../server/instance-manager.js"
-import chalk from "chalk"
 import { getCloudDistributionName, sleep } from "../util/util.js"
 import type { Log } from "../logger/log-entry.js"
 import { findProjectConfig } from "../config/base.js"
@@ -23,6 +22,7 @@ import { uuidv4 } from "../util/random.js"
 import type { Garden } from "../garden.js"
 import type { GardenPluginReference } from "../plugin/plugin.js"
 import { CommandError, ParameterError, isEAddrInUseException, isErrnoException } from "../exceptions.js"
+import { styles } from "../logger/styles.js"
 
 export const defaultServerPort = 9777
 
@@ -160,8 +160,8 @@ export class ServeCommand<
         await garden.emitWarning({
           key: "web-app",
           log,
-          message: chalk.green(
-            `🌿 Explore logs, past commands, and your dependency graph in the Garden web App. Log in with ${chalk.cyan(
+          message: styles.success(
+            `🌿 Explore logs, past commands, and your dependency graph in the Garden web App. Log in with ${styles.highlight(
               "garden login"
             )}.`
           ),
@@ -190,13 +190,13 @@ export class ServeCommand<
           })
           if (session?.shortId) {
             const distroName = getCloudDistributionName(cloudDomain)
-            const livePageUrl = cloudApi.getLivePageUrl({ shortId: session.shortId })
+            const livePageUrl = cloudApi.getLivePageUrl({ shortId: session.shortId }).toString()
             const msg = dedent`${printEmoji("🌸", log)}Connected to ${distroName} ${printEmoji("🌸", log)}
               Follow the link below to stream logs, run commands, and more from your web dashboard ${printEmoji(
                 "πŸ‘‡",
                 log
-              )} \n\n${chalk.cyan(livePageUrl)}\n`
-            log.info(chalk.white(msg))
+              )} \n\n${styles.highlight(livePageUrl)}\n`
+            log.info(styles.accent(msg))
           }
         }
       }
@@ -204,9 +204,9 @@ export class ServeCommand<
       if (err instanceof CloudApiTokenRefreshError) {
         const distroName = getCloudDistributionName(cloudDomain)
         log.warn(dedent`
-          ${chalk.yellow(`Unable to authenticate against ${distroName} with the current session token.`)}
+          Unable to authenticate against ${distroName} with the current session token.
           The dashboard will not be available until you authenticate again. Please try logging out with
-          ${chalk.bold("garden logout")} and back in again with ${chalk.bold("garden login")}.
+          ${styles.command("garden logout")} and back in again with ${styles.command("garden login")}.
         `)
       } else {
         // Unhandled error when creating the cloud api
@@ -232,7 +232,7 @@ export class ServeCommand<
               await sleep(1000)
             }
           }
-          this.commandLine?.flashSuccess(chalk.white.bold(`Dev console is ready to go! πŸš€`))
+          this.commandLine?.flashSuccess(styles.accent.bold(`Dev console is ready to go! πŸš€`))
           this.commandLine?.enable()
         })
         // Errors are handled in the method
diff --git a/core/src/commands/set.ts b/core/src/commands/set.ts
index 356d30591f..1113b709e7 100644
--- a/core/src/commands/set.ts
+++ b/core/src/commands/set.ts
@@ -6,12 +6,12 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { EnvironmentParameter } from "../cli/params.js"
 import { dedent } from "../util/string.js"
 import type { CommandParams } from "./base.js"
 import { Command, CommandGroup } from "./base.js"
 import { ConfigurationError } from "../exceptions.js"
+import { styles } from "../logger/styles.js"
 
 export class SetCommand extends CommandGroup {
   name = "set"
@@ -64,9 +64,9 @@ export class SetDefaultEnvCommand extends Command<SetDefaultEnvArgs, {}> {
     log.info("")
 
     if (args.env) {
-      log.success(chalk.white(`Set the default environment to ${chalk.cyan(args.env)}`))
+      log.success(styles.accent(`Set the default environment to ${styles.highlight(args.env)}`))
     } else {
-      log.success(chalk.white("Cleared the default environment"))
+      log.success(styles.accent("Cleared the default environment"))
     }
 
     return {}
diff --git a/core/src/commands/sync/sync-restart.ts b/core/src/commands/sync/sync-restart.ts
index dae12f18bf..24fb7ebf5b 100644
--- a/core/src/commands/sync/sync-restart.ts
+++ b/core/src/commands/sync/sync-restart.ts
@@ -12,7 +12,6 @@ import { printHeader } from "../../logger/util.js"
 import { dedent, naturalList } from "../../util/string.js"
 import type { CommandParams, CommandResult } from "../base.js"
 import { Command } from "../base.js"
-import chalk from "chalk"
 import { createActionLog } from "../../logger/log-entry.js"
 import { startSyncWithoutDeploy } from "./sync-start.js"
 
@@ -89,7 +88,7 @@ export class SyncRestartCommand extends Command<Args, Opts> {
     actions = actions.filter((action) => {
       if (!action.supportsMode("sync")) {
         if (names.includes(action.name)) {
-          log.warn(chalk.yellow(`${action.longDescription()} does not support syncing.`))
+          log.warn(`${action.longDescription()} does not support syncing.`)
         }
         return false
       }
@@ -97,7 +96,7 @@ export class SyncRestartCommand extends Command<Args, Opts> {
     })
 
     if (actions.length === 0) {
-      log.warn(chalk.yellow(`No matched action supports syncing. Aborting.`))
+      log.warn(`No matched action supports syncing. Aborting.`)
       return {}
     }
 
@@ -113,9 +112,9 @@ export class SyncRestartCommand extends Command<Args, Opts> {
         await router.deploy.stopSync({ log: actionLog, action, graph })
       })
     )
-    syncControlLog.info({ symbol: "success", msg: chalk.green("Active syncs stopped") })
+    syncControlLog.success("Active syncs stopped")
 
-    syncControlLog.info({ symbol: "info", msg: "Starting stopped syncs..." })
+    syncControlLog.info("Starting stopped syncs...")
 
     await startSyncWithoutDeploy({
       actions,
@@ -127,7 +126,7 @@ export class SyncRestartCommand extends Command<Args, Opts> {
       stopOnExit: false,
     })
 
-    log.info(chalk.green("\nDone!"))
+    log.success({ msg: "\nDone!", showDuration: false })
 
     return {}
   }
diff --git a/core/src/commands/sync/sync-start.ts b/core/src/commands/sync/sync-start.ts
index 9b7bf5b19f..d8af15a426 100644
--- a/core/src/commands/sync/sync-start.ts
+++ b/core/src/commands/sync/sync-start.ts
@@ -13,7 +13,6 @@ import { DeployTask } from "../../tasks/deploy.js"
 import { dedent, naturalList } from "../../util/string.js"
 import type { CommandParams, CommandResult, PrepareParams } from "../base.js"
 import { Command } from "../base.js"
-import chalk from "chalk"
 import { ParameterError, RuntimeError } from "../../exceptions.js"
 import { SyncMonitor } from "../../monitors/sync.js"
 import type { Log } from "../../logger/log-entry.js"
@@ -125,7 +124,7 @@ export class SyncStartCommand extends Command<Args, Opts> {
       const actionLog = createActionLog({ log, actionName: action.name, actionKind: action.kind })
       if (!action.supportsMode("sync")) {
         if (names.includes(action.name)) {
-          actionLog.warn(chalk.yellow(`${action.longDescription()} does not support syncing.`))
+          actionLog.warn(`${action.longDescription()} does not support syncing.`)
         } else {
           actionLog.debug(`${action.longDescription()} does not support syncing.`)
         }
@@ -161,7 +160,7 @@ export class SyncStartCommand extends Command<Args, Opts> {
         return task
       })
       await garden.processTasks({ tasks, log })
-      log.info(chalk.green("\nDone!"))
+      log.success({ msg: "\nDone!", showDuration: false })
       return {}
     } else {
       // Don't deploy, just start syncs
@@ -175,7 +174,7 @@ export class SyncStartCommand extends Command<Args, Opts> {
         stopOnExit,
       })
       if (garden.monitors.getAll().length === 0) {
-        log.info(chalk.green("\nDone!"))
+        log.success({ msg: "\nDone!", showDuration: false })
       }
       return {}
     }
@@ -231,9 +230,7 @@ export async function startSyncWithoutDeploy({
       if (executedAction && (state === "outdated" || state === "ready")) {
         if (mode !== "sync") {
           actionLog.warn(
-            chalk.yellow(
-              `Not deployed in sync mode, cannot start sync. Try running this command with \`--deploy\` set.`
-            )
+            `Not deployed in sync mode, cannot start sync. Try running this command with \`--deploy\` set.`
           )
           return
         }
@@ -248,15 +245,15 @@ export async function startSyncWithoutDeploy({
           }
         } catch (error) {
           actionLog.warn(
-            chalk.yellow(dedent`
+            dedent`
             Failed starting sync for ${action.longDescription()}: ${error}
 
             You may need to re-deploy the action. Try running this command with \`--deploy\` set, or running \`garden deploy --sync\` before running this command again.
-          `)
+          `
           )
         }
       } else {
-        actionLog.warn(chalk.yellow(`${action.longDescription()} is not deployed, cannot start sync.`))
+        actionLog.warn(`${action.longDescription()} is not deployed, cannot start sync.`)
       }
     })
   )
diff --git a/core/src/commands/sync/sync-status.ts b/core/src/commands/sync/sync-status.ts
index b12a119029..303b2e2401 100644
--- a/core/src/commands/sync/sync-status.ts
+++ b/core/src/commands/sync/sync-status.ts
@@ -6,8 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
-
 import { BooleanParameter, StringsParameter } from "../../cli/params.js"
 import { joi } from "../../config/common.js"
 import { printHeader } from "../../logger/util.js"
@@ -25,6 +23,7 @@ import type { ResolvedDeployAction } from "../../actions/deploy.js"
 import type { ResolvedConfigGraph } from "../../graph/config-graph.js"
 import { DOCS_BASE_URL } from "../../constants.js"
 import pMap from "p-map"
+import { styles } from "../../logger/styles.js"
 
 const syncStatusArgs = {
   names: new StringsParameter({
@@ -112,7 +111,7 @@ export class SyncStatusCommand extends Command<Args, Opts> {
         Follow the link below to learn how to enable live code syncing with Garden:
       `)
       log.info("")
-      log.info(chalk.cyan.underline(`${DOCS_BASE_URL}/guides/code-synchronization`))
+      log.info(styles.highlight.underline(`${DOCS_BASE_URL}/guides/code-synchronization`))
     }
 
     return { result: { actions: syncStatuses } }
@@ -122,10 +121,10 @@ export class SyncStatusCommand extends Command<Args, Opts> {
 function stateStyle(state: SyncState, msg: string) {
   const styleFn =
     {
-      "active": chalk.green,
-      "failed": chalk.red,
-      "not-active": chalk.yellow,
-    }[state] || chalk.bold.dim
+      "active": styles.success,
+      "failed": styles.error,
+      "not-active": styles.warning,
+    }[state] || styles.primary.bold
   return styleFn(msg)
 }
 
@@ -198,21 +197,22 @@ export async function getSyncStatuses({
       const syncCount = syncStatus.syncs.length
       const pluralizedSyncs = syncCount === 1 ? "sync" : "syncs"
       log.info(
-        `The ${chalk.cyan(action.name)} Deploy action has ${chalk.cyan(syncCount)} ${pluralizedSyncs} configured:`
+        `The ${styles.highlight(action.name)} Deploy action has ${styles.highlight(
+          syncCount.toString()
+        )} ${pluralizedSyncs} configured:`
       )
       const leftPad = "  β†’"
       syncs.forEach((sync, idx) => {
         const state = sync.state
         log.info(
-          `${leftPad} Sync from ${chalk.cyan(sync.source)} to ${chalk.cyan(sync.target)} ${verbMap[state]} ${stateStyle(
-            state,
-            describeState(state)
-          )}`
+          `${leftPad} Sync from ${styles.highlight(sync.source)} to ${styles.highlight(sync.target)} ${
+            verbMap[state]
+          } ${stateStyle(state, describeState(state))}`
         )
-        sync.mode && log.info(chalk.bold(`${leftPad} Mode: ${sync.mode}`))
-        sync.syncCount && log.info(chalk.bold(`${leftPad} Number of completed syncs: ${sync.syncCount}`))
+        sync.mode && log.info(styles.bold(`${leftPad} Mode: ${sync.mode}`))
+        sync.syncCount && log.info(styles.bold(`${leftPad} Number of completed syncs: ${sync.syncCount}`))
         if (state === "failed" && sync.message) {
-          log.info(`${chalk.bold(leftPad)} ${chalk.yellow(sync.message)}`)
+          log.info(`${styles.bold(leftPad)} ${styles.warning(sync.message)}`)
         }
         idx !== syncs.length - 1 && log.info("")
       })
diff --git a/core/src/commands/sync/sync-stop.ts b/core/src/commands/sync/sync-stop.ts
index 749189f238..cc1805fb22 100644
--- a/core/src/commands/sync/sync-stop.ts
+++ b/core/src/commands/sync/sync-stop.ts
@@ -12,7 +12,6 @@ import { printHeader } from "../../logger/util.js"
 import { dedent, naturalList } from "../../util/string.js"
 import type { CommandParams, CommandResult } from "../base.js"
 import { Command } from "../base.js"
-import chalk from "chalk"
 import { createActionLog } from "../../logger/log-entry.js"
 
 const syncStopArgs = {
@@ -86,7 +85,7 @@ export class SyncStopCommand extends Command<Args, Opts> {
     actions = actions.filter((action) => {
       if (!action.supportsMode("sync")) {
         if (names.includes(action.name)) {
-          log.warn(chalk.yellow(`${action.longDescription()} does not support syncing.`))
+          log.warn(`${action.longDescription()} does not support syncing.`)
         }
         return false
       }
@@ -94,7 +93,7 @@ export class SyncStopCommand extends Command<Args, Opts> {
     })
 
     if (actions.length === 0) {
-      log.warn(chalk.yellow(`No matched action supports syncing. Aborting.`))
+      log.warn(`No matched action supports syncing. Aborting.`)
       return {}
     }
 
@@ -114,7 +113,7 @@ export class SyncStopCommand extends Command<Args, Opts> {
       })
     )
 
-    log.info(chalk.green("\nDone!"))
+    log.success({ msg: "\nDone!", showDuration: false })
 
     return {}
   }
diff --git a/core/src/commands/tools.ts b/core/src/commands/tools.ts
index 7004360449..39823220a7 100644
--- a/core/src/commands/tools.ts
+++ b/core/src/commands/tools.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { max, omit, sortBy } from "lodash-es"
 import { dedent, naturalList, renderTable, tablePresets } from "../util/string.js"
 import type { Log } from "../logger/log-entry.js"
@@ -19,6 +18,7 @@ import { uniqByName, exec, shutdown } from "../util/util.js"
 import { PluginTool } from "../util/ext-tools.js"
 import { findProjectConfig } from "../config/base.js"
 import { StringOption, BooleanParameter } from "../cli/params.js"
+import { styles } from "../logger/styles.js"
 
 const toolsArgs = {
   tool: new StringOption({
@@ -155,7 +155,7 @@ export class ToolsCommand extends Command<Args, Opts> {
     }
 
     if (matchedTools.length > 1) {
-      log.warn(chalk.yellow(`Multiple tools matched (${matchedNames.join(", ")}). Running ${matchedNames[0]}`))
+      log.warn(`Multiple tools matched (${matchedNames.join(", ")}). Running ${matchedNames[0]}`)
     }
 
     const toolCls = new PluginTool(matchedTools[0].tool)
@@ -192,20 +192,20 @@ async function getTools(garden: Garden) {
 
 async function printTools(garden: Garden, log: Log) {
   log.info(dedent`
-  ${chalk.white.bold("USAGE")}
+  ${styles.accent.bold("USAGE")}
 
-    garden ${chalk.yellow("[global options]")} ${chalk.blueBright("<tool>")} -- ${chalk.white("[args ...]")}
-    garden ${chalk.yellow("[global options]")} ${chalk.blueBright("<tool>")} --get-path
+    garden ${styles.warning("[global options]")} ${styles.highlight("<tool>")} -- ${styles.accent("[args ...]")}
+    garden ${styles.warning("[global options]")} ${styles.highlight("<tool>")} --get-path
 
-  ${chalk.white.bold("PLUGIN TOOLS")}
+  ${styles.accent.bold("PLUGIN TOOLS")}
   `)
 
   const tools = await getTools(garden)
 
   const rows = tools.map((tool) => {
     return [
-      ` ${chalk.cyan(tool.pluginName + ".")}${chalk.cyan.bold(tool.name)}`,
-      chalk.gray(`[${tool.type}]`),
+      ` ${styles.highlight(tool.pluginName + ".")}${styles.highlight.bold(tool.name)}`,
+      styles.primary(`[${tool.type}]`),
       tool.description,
     ]
   })
diff --git a/core/src/commands/up.ts b/core/src/commands/up.ts
index 554f056b7f..c0179744f2 100644
--- a/core/src/commands/up.ts
+++ b/core/src/commands/up.ts
@@ -6,13 +6,13 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type { CommandParams, CommandResult } from "./base.js"
 import { Command } from "./base.js"
 import { dedent } from "../util/string.js"
 import type { deployArgs, deployOpts } from "./deploy.js"
 import type { serveOpts } from "./serve.js"
 import type { LoggerType } from "../logger/logger.js"
+import { styles } from "../logger/styles.js"
 
 type UpArgs = typeof deployArgs
 type UpOpts = typeof deployOpts & typeof serveOpts
@@ -25,9 +25,9 @@ export class UpCommand extends Command<UpArgs, UpOpts> {
   override description = dedent`
     Spin up your stack with the dev console and streaming logs.
 
-    This is basically an alias for ${chalk.cyanBright(
+    This is basically an alias for ${styles.highlight(
       "garden dev --cmd 'deploy --logs'"
-    )}, but you can add any arguments and flags supported by the ${chalk.cyanBright("deploy")} command as well.
+    )}, but you can add any arguments and flags supported by the ${styles.highlight("deploy")} command as well.
   `
 
   override getTerminalWriterType(): LoggerType {
diff --git a/core/src/commands/update-remote/actions.ts b/core/src/commands/update-remote/actions.ts
index dfaf0ace31..eb613d87e1 100644
--- a/core/src/commands/update-remote/actions.ts
+++ b/core/src/commands/update-remote/actions.ts
@@ -8,7 +8,6 @@
 
 import { difference } from "lodash-es"
 import dedent from "dedent"
-import chalk from "chalk"
 
 import type { CommandResult, CommandParams } from "../base.js"
 import { Command } from "../base.js"
@@ -24,6 +23,7 @@ import type { ParameterValues } from "../../cli/params.js"
 import { StringsParameter } from "../../cli/params.js"
 import pMap from "p-map"
 import { naturalList } from "../../util/string.js"
+import { styles } from "../../logger/styles.js"
 
 const updateRemoteActionsArguments = {
   actions: new StringsParameter({
@@ -110,7 +110,7 @@ export async function updateRemoteActions({
 
     throw new ParameterError({
       message: dedent`
-        Expected action(s) ${chalk.underline(diff.join(","))} to have a remote source.
+        Expected action(s) ${styles.underline(diff.join(","))} to have a remote source.
         Actions with remote source: ${naturalList(actionsWithRemoteSource.map((a) => a.name))}
       `,
     })
diff --git a/core/src/commands/update-remote/modules.ts b/core/src/commands/update-remote/modules.ts
index ac198e8744..bb0291b40c 100644
--- a/core/src/commands/update-remote/modules.ts
+++ b/core/src/commands/update-remote/modules.ts
@@ -8,7 +8,6 @@
 
 import { difference } from "lodash-es"
 import dedent from "dedent"
-import chalk from "chalk"
 
 import type { CommandResult, CommandParams } from "../base.js"
 import { Command } from "../base.js"
@@ -24,6 +23,7 @@ import { joiArray, joi } from "../../config/common.js"
 import type { ParameterValues } from "../../cli/params.js"
 import { StringsParameter } from "../../cli/params.js"
 import { naturalList } from "../../util/string.js"
+import { styles } from "../../logger/styles.js"
 
 const updateRemoteModulesArguments = {
   modules: new StringsParameter({
@@ -106,7 +106,7 @@ export async function updateRemoteModules({
 
     throw new ParameterError({
       message: dedent`
-        Expected module(s) ${chalk.underline(diff.join(","))} to have a remote source.
+        Expected module(s) ${styles.underline(diff.join(","))} to have a remote source.
         Modules with remote source: ${naturalList(modulesWithRemoteSource.map((m) => m.name))}
       `,
     })
diff --git a/core/src/commands/update-remote/sources.ts b/core/src/commands/update-remote/sources.ts
index ca1aeef22f..17643182ee 100644
--- a/core/src/commands/update-remote/sources.ts
+++ b/core/src/commands/update-remote/sources.ts
@@ -8,7 +8,6 @@
 
 import { difference } from "lodash-es"
 import dedent from "dedent"
-import chalk from "chalk"
 
 import type { CommandResult, CommandParams } from "../base.js"
 import { Command } from "../base.js"
@@ -23,6 +22,7 @@ import { joiArray, joi } from "../../config/common.js"
 import type { ParameterValues } from "../../cli/params.js"
 import { StringsParameter } from "../../cli/params.js"
 import { naturalList } from "../../util/string.js"
+import { styles } from "../../logger/styles.js"
 
 const updateRemoteSourcesArguments = {
   sources: new StringsParameter({
@@ -99,7 +99,7 @@ export async function updateRemoteSources({
   if (diff.length > 0) {
     throw new ParameterError({
       message: dedent`
-        Expected source(s) ${chalk.underline(diff.join(","))} to be specified in the project garden.yml config.
+        Expected source(s) ${styles.underline(diff.join(","))} to be specified in the project garden.yml config.
         Configured remote sources: ${naturalList(projectSources.map((s) => s.name).sort())}
       `,
     })
diff --git a/core/src/commands/util/watch-parameter.ts b/core/src/commands/util/watch-parameter.ts
index b3ba1bdf5e..26ebe6f6eb 100644
--- a/core/src/commands/util/watch-parameter.ts
+++ b/core/src/commands/util/watch-parameter.ts
@@ -5,7 +5,6 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
-import chalk from "chalk"
 import { BooleanParameter } from "../../cli/params.js"
 import type { Garden } from "../../garden.js"
 import type { Log } from "../../logger/log-entry.js"
@@ -21,8 +20,7 @@ export async function watchRemovedWarning(garden: Garden, log: Log) {
   return garden.emitWarning({
     log,
     key: "watch-flag-removed",
-    message: chalk.yellow(
-      "The -w/--watch flag has been removed. Please use other options instead, such as the --sync option for Deploy actions. If you need this feature and would like it re-introduced, please don't hesitate to reach out: https://garden.io/community"
-    ),
+    message:
+      "The -w/--watch flag has been removed. Please use other options instead, such as the --sync option for Deploy actions. If you need this feature and would like it re-introduced, please don't hesitate to reach out: https://garden.io/community",
   })
 }
diff --git a/core/src/commands/validate.ts b/core/src/commands/validate.ts
index 10537ff618..31999f0674 100644
--- a/core/src/commands/validate.ts
+++ b/core/src/commands/validate.ts
@@ -6,12 +6,12 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type { CommandParams, CommandResult } from "./base.js"
 import { Command } from "./base.js"
 import { printEmoji, printHeader } from "../logger/util.js"
-import { resolveWorkflowConfig } from "../config/workflow.js"
 import { dedent } from "../util/string.js"
+import { styles } from "../logger/styles.js"
+import { resolveWorkflowConfig } from "../config/workflow.js"
 
 export class ValidateCommand extends Command {
   name = "validate"
@@ -44,7 +44,7 @@ export class ValidateCommand extends Command {
     }
 
     log.info("")
-    log.info(chalk.green("OK") + " " + printEmoji("βœ”οΈ", log))
+    log.info(styles.success("OK") + " " + printEmoji("βœ”οΈ", log))
 
     return {}
   }
diff --git a/core/src/commands/workflow.ts b/core/src/commands/workflow.ts
index 9de0f4c7ca..98aa22eb8a 100644
--- a/core/src/commands/workflow.ts
+++ b/core/src/commands/workflow.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import cloneDeep from "fast-copy"
 import { flatten, last, repeat, size } from "lodash-es"
 import { printHeader, getTerminalWidth, renderMessageWithDivider, renderDuration } from "../logger/util.js"
@@ -35,6 +34,7 @@ import { StringParameter } from "../cli/params.js"
 import type { GardenCli } from "../cli/cli.js"
 import { getCustomCommands } from "./custom.js"
 import { getBuiltinCommands } from "./commands.js"
+import { styles } from "../logger/styles.js"
 
 const runWorkflowArgs = {
   workflow: new StringParameter({
@@ -70,7 +70,7 @@ export class WorkflowCommand extends Command<Args, {}> {
   override arguments = runWorkflowArgs
 
   override printHeader({ log, args }) {
-    printHeader(log, `Running workflow ${chalk.white(args.workflow)}`, "πŸƒβ€β™‚οΈ")
+    printHeader(log, `Running workflow ${styles.accent(args.workflow)}`, "πŸƒβ€β™‚οΈ")
   }
 
   async action({ cli, garden, log, args, opts }: CommandParams<Args, {}>): Promise<CommandResult<WorkflowRunOutput>> {
@@ -121,7 +121,9 @@ export class WorkflowCommand extends Command<Args, {}> {
       garden.log.info({ metadata })
 
       if (step.skip) {
-        stepBodyLog.info(chalk.yellow(`Skipping step ${chalk.white(index + 1)}/${chalk.white(steps.length)}`))
+        stepBodyLog.info(
+          styles.warning(`Skipping step ${styles.accent(String(index + 1))}/${styles.accent(String(steps.length))}`)
+        )
         result.steps[stepName] = {
           number: index + 1,
           outputs: {},
@@ -266,25 +268,25 @@ export function printStepHeader(log: Log, stepIndex: number, stepCount: number,
   const maxWidth = Math.min(getTerminalWidth(), minWidth)
   const text = `Running step ${formattedStepDescription(stepIndex, stepCount, stepDescription)}`
   const header = dedent`
-    ${chalk.cyan.bold(wordWrap(text, maxWidth))}
+    ${styles.highlight.bold(wordWrap(text, maxWidth))}
     ${getStepSeparatorBar()}
   `
   log.info(header)
 }
 
 function getSeparatorBar(width: number) {
-  return chalk.white(repeat("═", width))
+  return styles.accent(repeat("═", width))
 }
 
 export function printStepDuration({ outerLog, stepIndex, bodyLog, stepCount, success }: RunStepLogParams) {
   const durationSecs = bodyLog.getDuration()
-  const result = success ? chalk.green("completed") : chalk.red("failed")
+  const result = success ? styles.success("completed") : styles.error("failed")
 
   const text = deline`
-    Step ${formattedStepNumber(stepIndex, stepCount)} ${chalk.bold(result)} in
-    ${chalk.white(durationSecs)} Sec
+    Step ${formattedStepNumber(stepIndex, stepCount)} ${styles.bold(result)} in
+    ${styles.accent(String(durationSecs))} Sec
   `
-  outerLog.info(`${getStepSeparatorBar()}\n${chalk.cyan.bold(text)}\n`)
+  outerLog.info(`${getStepSeparatorBar()}\n${styles.highlight.bold(text)}\n`)
 }
 
 function getStepSeparatorBar() {
@@ -295,13 +297,13 @@ function getStepSeparatorBar() {
 export function formattedStepDescription(stepIndex: number, stepCount: number, stepDescription?: string) {
   let formatted = formattedStepNumber(stepIndex, stepCount)
   if (stepDescription) {
-    formatted += ` β€” ${chalk.white(stepDescription)}`
+    formatted += ` β€” ${styles.accent(stepDescription)}`
   }
   return formatted
 }
 
 export function formattedStepNumber(stepIndex: number, stepCount: number) {
-  return `${chalk.white(stepIndex + 1)}/${chalk.white(stepCount)}`
+  return `${styles.accent(String(stepIndex + 1))}/${styles.accent(String(stepCount))}`
 }
 
 function printResult({
@@ -318,12 +320,12 @@ function printResult({
   const completedAt = new Date().valueOf()
   const totalDuration = ((completedAt - startedAt) / 1000).toFixed(2)
 
-  const resultColor = success ? chalk.magenta.bold : chalk.red.bold
+  const resultColor = success ? styles.success.bold : styles.error.bold
   const resultMessage = success ? "completed successfully" : "failed"
 
   log.info(
-    resultColor(`Workflow ${chalk.white.bold(workflow.name)} ${resultMessage}. `) +
-      chalk.magenta(`Total time elapsed: ${chalk.white.bold(totalDuration)} Sec.`)
+    resultColor(`Workflow ${styles.accent.bold(workflow.name)} ${resultMessage}. `) +
+      styles.highlightSecondary(`Total time elapsed: ${styles.accent.bold(totalDuration)} Sec.`)
   )
 }
 
@@ -460,8 +462,8 @@ export function logErrors(
   stepDescription?: string
 ) {
   const description = formattedStepDescription(stepIndex, stepCount, stepDescription)
-  const errMsg = `An error occurred while running step ${chalk.white(description)}.\n`
-  log.error(chalk.red(errMsg))
+  const errMsg = `An error occurred while running step ${styles.accent(description)}.\n`
+  log.error(errMsg)
   log.debug("")
   for (const error of errors) {
     if (error instanceof WorkflowScriptError) {
@@ -473,7 +475,7 @@ export function logErrors(
       log.error(scriptErrMsg)
     } else {
       const taskDetailErrMsg = error.toString(true)
-      log.debug(chalk.red(taskDetailErrMsg))
+      log.debug(taskDetailErrMsg)
       log.error(error.explain() + "\n")
     }
   }
diff --git a/core/src/config/project.ts b/core/src/config/project.ts
index a9be6cf2ad..6a86db36eb 100644
--- a/core/src/config/project.ts
+++ b/core/src/config/project.ts
@@ -38,9 +38,9 @@ import type { VcsInfo } from "../vcs/vcs.js"
 import { profileAsync } from "../util/profiling.js"
 import type { BaseGardenResource } from "./base.js"
 import { baseInternalFieldsSchema, loadVarfile, varfileDescription } from "./base.js"
-import chalk from "chalk"
 import type { Log } from "../logger/log-entry.js"
 import { renderDivider } from "../logger/util.js"
+import { styles } from "../logger/styles.js"
 
 export const defaultVarfilePath = "garden.env"
 export const defaultEnvVarfilePath = (environmentName: string) => `garden.${environmentName}.env`
@@ -504,7 +504,7 @@ export function resolveProjectConfig({
     })
   } catch (err) {
     log.error("Failed to resolve project configuration.")
-    log.error(chalk.red.bold(renderDivider()))
+    log.error(styles.bold(renderDivider()))
     throw err
   }
 
@@ -699,10 +699,10 @@ export function getNamespace(environmentConfig: EnvironmentConfig, namespace: st
   }
 
   if (!namespace) {
-    const exampleFlag = chalk.white(`--env=${chalk.bold("some-namespace.")}${envName}`)
+    const exampleFlag = styles.accent(`--env=${styles.bold("some-namespace.")}${envName}`)
 
     throw new ParameterError({
-      message: `Environment ${chalk.white.bold(
+      message: `Environment ${styles.accent.bold(
         envName
       )} has defaultNamespace set to null in the project configuration, and no explicit namespace was specified. Please either set a defaultNamespace or explicitly set a namespace at runtime (e.g. ${exampleFlag}).`,
     })
diff --git a/core/src/config/template-contexts/actions.ts b/core/src/config/template-contexts/actions.ts
index 6ee128eba1..9d0d58cf8b 100644
--- a/core/src/config/template-contexts/actions.ts
+++ b/core/src/config/template-contexts/actions.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { merge } from "lodash-es"
 import type { ActionConfig, Action, ExecutedAction, ResolvedAction } from "../../actions/types.js"
 import type { ActionMode } from "../../actions/types.js"
@@ -21,6 +20,7 @@ import { exampleVersion, OutputConfigContext } from "./module.js"
 import { TemplatableConfigContext } from "./project.js"
 import { DOCS_BASE_URL } from "../../constants.js"
 import type { WorkflowConfig } from "../workflow.js"
+import { styles } from "../../logger/styles.js"
 
 function mergeVariables({ garden, variables }: { garden: Garden; variables: DeepPrimitiveMap }): DeepPrimitiveMap {
   const mergedVariables: DeepPrimitiveMap = {}
@@ -302,7 +302,7 @@ export class ActionSpecContext extends OutputConfigContext {
     // Throw specific error when attempting to resolve self
     this.actions[action.kind.toLowerCase()].set(
       name,
-      new ErrorContext(`Action ${chalk.white.bold(action.key())} cannot reference itself.`)
+      new ErrorContext(`Action ${styles.accent.bold(action.key())} cannot reference itself.`)
     )
 
     if (parentName && templateName) {
diff --git a/core/src/config/template-contexts/base.ts b/core/src/config/template-contexts/base.ts
index 7362f95097..915dcc67b4 100644
--- a/core/src/config/template-contexts/base.ts
+++ b/core/src/config/template-contexts/base.ts
@@ -7,7 +7,6 @@
  */
 
 import type Joi from "@hapi/joi"
-import chalk from "chalk"
 import { isString } from "lodash-es"
 import { ConfigurationError } from "../../exceptions.js"
 import {
@@ -19,6 +18,7 @@ import type { CustomObjectSchema } from "../common.js"
 import { isPrimitive, joi, joiIdentifier } from "../common.js"
 import { KeyedSet } from "../../util/keyed-set.js"
 import { naturalList } from "../../util/string.js"
+import { styles } from "../../logger/styles.js"
 
 export type ContextKeySegment = string | number
 export type ContextKey = ContextKeySegment[]
@@ -170,15 +170,15 @@ export abstract class ConfigContext {
 
     if (value === undefined) {
       if (message === undefined) {
-        message = chalk.red(`Could not find key ${chalk.white(nextKey)}`)
+        message = styles.error(`Could not find key ${styles.accent(String(nextKey))}`)
         if (nestedNodePath.length > 1) {
-          message += chalk.red(" under ") + chalk.white(renderKeyPath(nestedNodePath.slice(0, -1)))
+          message += styles.error(" under ") + styles.accent(renderKeyPath(nestedNodePath.slice(0, -1)))
         }
-        message += chalk.red(".")
+        message += styles.error(".")
 
         if (available) {
-          const availableStr = available.length ? naturalList(available.sort().map((k) => chalk.white(k))) : "(none)"
-          message += chalk.red(" Available keys: " + availableStr + ".")
+          const availableStr = available.length ? naturalList(available.sort().map((k) => styles.accent(k))) : "(none)"
+          message += styles.error(" Available keys: " + availableStr + ".")
         }
         const messageFooter = this.getMissingKeyErrorFooter(nextKey, nestedNodePath.slice(0, -1))
         if (messageFooter) {
diff --git a/core/src/config/template-contexts/module.ts b/core/src/config/template-contexts/module.ts
index d54dd12d68..f4e0f505b9 100644
--- a/core/src/config/template-contexts/module.ts
+++ b/core/src/config/template-contexts/module.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type { PrimitiveMap, DeepPrimitiveMap } from "../common.js"
 import { joiIdentifierMap, joiPrimitive, joiVariables, joiIdentifier } from "../common.js"
 import type { ProviderMap } from "../provider.js"
@@ -21,6 +20,7 @@ import type { GraphResultFromTask, GraphResults } from "../../graph/results.js"
 import type { DeployTask } from "../../tasks/deploy.js"
 import type { RunTask } from "../../tasks/run.js"
 import { DOCS_BASE_URL } from "../../constants.js"
+import { styles } from "../../logger/styles.js"
 
 export const exampleVersion = "v-17ad4cb3fd"
 
@@ -289,7 +289,7 @@ export class ModuleConfigContext extends OutputConfigContext {
     const { name, path, inputs, parentName, templateName, buildPath } = params
 
     // Throw specific error when attempting to resolve self
-    this.modules.set(name, new ErrorContext(`Config ${chalk.white.bold(name)} cannot reference itself.`))
+    this.modules.set(name, new ErrorContext(`Config ${styles.accent.bold(name)} cannot reference itself.`))
 
     if (parentName && templateName) {
       this.parent = new ParentContext(this, parentName)
diff --git a/core/src/config/template-contexts/project.ts b/core/src/config/template-contexts/project.ts
index 2f2dee4c1e..cf53ac5727 100644
--- a/core/src/config/template-contexts/project.ts
+++ b/core/src/config/template-contexts/project.ts
@@ -7,7 +7,6 @@
  */
 
 import { last, isEmpty } from "lodash-es"
-import chalk from "chalk"
 import type { PrimitiveMap, DeepPrimitiveMap } from "../common.js"
 import { joiIdentifierMap, joiStringMap, joiPrimitive, joiVariables } from "../common.js"
 import { joi } from "../common.js"
@@ -19,6 +18,7 @@ import type { Garden } from "../../garden.js"
 import type { VcsInfo } from "../../vcs/vcs.js"
 import type { ActionConfig } from "../../actions/types.js"
 import type { WorkflowConfig } from "../workflow.js"
+import { styles } from "../../logger/styles.js"
 
 class LocalContext extends ConfigContext {
   @schema(
@@ -316,7 +316,7 @@ export class ProjectConfigContext extends DefaultEnvironmentContext {
       return dedent`
         You are not logged in to Garden Cloud, but one or more secrets are referenced in template strings in your Garden configuration files.
 
-        Please log in via the ${chalk.green("garden login")} command to use Garden with secrets.
+        Please log in via the ${styles.command("garden login")} command to use Garden with secrets.
       `
     }
 
diff --git a/core/src/config/validation.ts b/core/src/config/validation.ts
index 0477197254..96e2481002 100644
--- a/core/src/config/validation.ts
+++ b/core/src/config/validation.ts
@@ -8,13 +8,13 @@
 
 import type Joi from "@hapi/joi"
 import { ConfigurationError } from "../exceptions.js"
-import chalk from "chalk"
 import { relative } from "path"
 import { uuidv4 } from "../util/random.js"
 import { profile } from "../util/profiling.js"
 import type { BaseGardenResource, YamlDocumentWithSource } from "./base.js"
 import type { ParsedNode, Range } from "yaml"
 import { padEnd } from "lodash-es"
+import { styles } from "../logger/styles.js"
 
 export const joiPathPlaceholder = uuidv4()
 const joiPathPlaceholderRegex = new RegExp(joiPathPlaceholder, "g")
@@ -162,8 +162,11 @@ export const validateSchema = profile(function $validateSchema<T>(
     }
 
     // a little hack to always use full key paths instead of just the label
-    e.message = e.message.replace(joiLabelPlaceholderRegex, renderedPath ? chalk.bold.underline(renderedPath) : "value")
-    e.message = e.message.replace(joiPathPlaceholderRegex, chalk.bold.underline(renderedPath || "."))
+    e.message = e.message.replace(
+      joiLabelPlaceholderRegex,
+      renderedPath ? styles.bold.underline(renderedPath) : "value"
+    )
+    e.message = e.message.replace(joiPathPlaceholderRegex, styles.bold.underline(renderedPath || "."))
     // FIXME: remove once we've customized the error output from AJV in customObject.jsonSchema()
     e.message = e.message.replace(/should NOT have/g, "should not have")
 
@@ -226,15 +229,18 @@ function addYamlContext({ rawYaml, range, message }: { rawYaml: string; range: R
     .slice(snippetStart, snippetEnd)
     .trimEnd()
     .split("\n")
-    .map((l, i) => chalk.gray(padEnd("" + (lineNumber - snippetLines + i), linePrefixLength) + "| ") + chalk.cyan(l))
+    .map(
+      (l, i) =>
+        styles.primary(padEnd("" + (lineNumber - snippetLines + i), linePrefixLength) + "| ") + styles.highlight(l)
+    )
     .join("\n")
 
   if (snippetStart > 0) {
-    snippet = chalk.gray("...\n") + snippet
+    snippet = styles.primary("...\n") + snippet
   }
 
   const errorLineOffset = range[0] - errorLineStart + linePrefixLength + 2
-  const marker = chalk.red("-".repeat(errorLineOffset)) + chalk.red.bold("^")
+  const marker = styles.error("-".repeat(errorLineOffset)) + styles.error.bold("^")
 
-  return `\n${snippet}\n${marker}\n${chalk.red.bold(message)}`
+  return `\n${snippet}\n${marker}\n${styles.error.bold(message)}`
 }
diff --git a/core/src/exceptions.ts b/core/src/exceptions.ts
index 2e58364b49..489860ee68 100644
--- a/core/src/exceptions.ts
+++ b/core/src/exceptions.ts
@@ -10,12 +10,12 @@ import { isString, trimEnd, truncate } from "lodash-es"
 import type { SpawnOpts } from "./util/util.js"
 import { testFlags } from "./util/util.js"
 import dedent from "dedent"
-import chalk from "chalk"
 import stripAnsi from "strip-ansi"
 import type { Cycle } from "./graph/common.js"
 import indentString from "indent-string"
 import { constants } from "os"
 import dns from "node:dns"
+import { styles } from "./logger/styles.js"
 
 // Unfortunately, NodeJS does not provide a list of all error codes, so we have to maintain this list manually.
 // See https://nodejs.org/docs/latest-v18.x/api/dns.html#error-codes
@@ -167,7 +167,7 @@ export abstract class GardenError extends Error {
    * @returns A string with ANSI-formatting.
    */
   explain(_context?: string): string {
-    return chalk.red(this.message)
+    return styles.error(this.message)
   }
 
   toJSON() {
@@ -422,7 +422,7 @@ export class InternalError extends GardenError {
       Please attach the following information to the bug report after making sure that the error message does not contain sensitive information:
     `
 
-    return chalk.red(`${chalk.bold(header)}\n\n${body}\n\n${chalk.gray(bugReportInformation)}`)
+    return styles.error(`${styles.bold(header)}\n\n${body}\n\n${styles.primary(bugReportInformation)}`)
   }
 }
 
diff --git a/core/src/garden.ts b/core/src/garden.ts
index f6ad48f29b..60820d13de 100644
--- a/core/src/garden.ts
+++ b/core/src/garden.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import fsExtra from "fs-extra"
 const { ensureDir } = fsExtra
 import { platform, arch } from "os"
@@ -167,6 +166,7 @@ import { GitRepoHandler } from "./vcs/git-repo.js"
 import { configureNoOpExporter } from "./util/open-telemetry/tracing.js"
 import { detectModuleOverlap, makeOverlapErrors } from "./util/module-overlap.js"
 import { GotHttpError } from "./util/http.js"
+import { styles } from "./logger/styles.js"
 
 const defaultLocalAddress = "localhost"
 
@@ -552,7 +552,7 @@ export class Garden {
 
       if (!existing || !existing.hidden) {
         this.emittedWarnings.add(key)
-        log.warn(message + `\n→ Run ${chalk.underline(`garden util hide-warning ${key}`)} to disable this warning.`)
+        log.warn(message + `\n→ Run ${styles.underline(`garden util hide-warning ${key}`)} to disable this warning.`)
       }
     })
   }
@@ -847,8 +847,8 @@ export class Garden {
       }
 
       if (gotCachedResult) {
-        providerLog.success("Cached")
-        providerLog.info(chalk.gray("Run with --force-refresh to force a refresh of provider statuses."))
+        providerLog.success({ msg: "Cached", showDuration: false })
+        providerLog.info("Run with --force-refresh to force a refresh of provider statuses.")
       } else {
         providerLog.success("Done")
       }
@@ -1155,7 +1155,7 @@ export class Garden {
     // This event is internal only, not to be streamed
     this.events.emit("configGraph", { graph })
 
-    graphLog.success(chalk.green("Done"))
+    graphLog.success("Done")
 
     return graph.toConfigGraph()
   }
@@ -2023,14 +2023,12 @@ async function getCloudProject({
   // If logged into commercial edition and ID is not set, log warning and return null
   if (!projectIdFromConfig) {
     log.warn(
-      chalk.yellow(
-        wordWrap(
-          deline`
+      wordWrap(
+        deline`
             Logged in to ${cloudApi.domain}, but could not find remote project '${projectName}'.
             Command results for this command run will not be available in ${distroName}.
           `,
-          120
-        )
+        120
       )
     )
 
@@ -2046,13 +2044,13 @@ async function getCloudProject({
     let errorMsg = `Fetching project with ID=${projectIdFromConfig} failed with error: ${err}`
     if (err instanceof GotHttpError) {
       if (err.response.statusCode === 404) {
-        const errorHeaderMsg = chalk.red(`Project with ID=${projectIdFromConfig} was not found in ${distroName}`)
-        const errorDetailMsg = chalk.white(dedent`
+        const errorHeaderMsg = styles.error(`Project with ID=${projectIdFromConfig} was not found in ${distroName}`)
+        const errorDetailMsg = styles.accent(dedent`
           Either the project has been deleted from ${distroName} or the ID in the project
-          level Garden config file at ${chalk.cyan(projectRoot)} has been changed and does not match
+          level Garden config file at ${styles.highlight(projectRoot)} has been changed and does not match
           one of the existing projects.
 
-          You can view your existing projects at ${chalk.cyan.underline(cloudApi.domain + "/projects")} and
+          You can view your existing projects at ${styles.highlight.underline(cloudApi.domain + "/projects")} and
           see their ID on the Settings page for the respective project.
         `)
         errorMsg = dedent`
diff --git a/core/src/graph/actions.ts b/core/src/graph/actions.ts
index 7d43e55fce..53ecae9648 100644
--- a/core/src/graph/actions.ts
+++ b/core/src/graph/actions.ts
@@ -59,7 +59,6 @@ import { mergeVariables } from "./common.js"
 import type { ConfigGraph } from "./config-graph.js"
 import { MutableConfigGraph } from "./config-graph.js"
 import type { ModuleGraph } from "./modules.js"
-import chalk from "chalk"
 import type { MaybeUndefined } from "../util/util.js"
 import minimatch from "minimatch"
 import type { ConfigContext } from "../config/template-contexts/base.js"
@@ -69,6 +68,7 @@ import { profileAsync } from "../util/profiling.js"
 import { uuidv4 } from "../util/random.js"
 import { getSourcePath } from "../vcs/vcs.js"
 import { actionIsDisabled } from "../actions/base.js"
+import { styles } from "../logger/styles.js"
 
 export const actionConfigsToGraph = profileAsync(async function actionConfigsToGraph({
   garden,
@@ -181,7 +181,7 @@ export const actionConfigsToGraph = profileAsync(async function actionConfigsToG
 
         if (!action.supportsMode(mode)) {
           if (explicitMode) {
-            log.warn(chalk.yellow(`${action.longDescription()} is not configured for or does not support ${mode} mode`))
+            log.warn(`${action.longDescription()} is not configured for or does not support ${mode} mode`)
           }
         }
 
@@ -193,11 +193,11 @@ export const actionConfigsToGraph = profileAsync(async function actionConfigsToG
 
         throw new ConfigurationError({
           message:
-            chalk.redBright(
-              `\nError processing config for ${chalk.white.bold(config.kind)} action ${chalk.white.bold(
+            styles.error(
+              `\nError processing config for ${styles.accent.bold(config.kind)} action ${styles.accent.bold(
                 config.name
               )}:\n`
-            ) + chalk.red(error.message),
+            ) + styles.error(error.message),
           wrappedErrors: [error],
         })
       }
diff --git a/core/src/graph/nodes.ts b/core/src/graph/nodes.ts
index 7ad33c9eeb..e6cde57a0a 100644
--- a/core/src/graph/nodes.ts
+++ b/core/src/graph/nodes.ts
@@ -11,9 +11,9 @@ import { GraphError, InternalError, toGardenError } from "../exceptions.js"
 import type { GraphResult, GraphResultFromTask } from "./results.js"
 import { GraphResults } from "./results.js"
 import type { GraphSolver } from "./solver.js"
-import chalk from "chalk"
 import { metadataForLog } from "./common.js"
 import { Profile } from "../util/profiling.js"
+import { styles } from "../logger/styles.js"
 
 export interface InternalNodeTypes {
   status: StatusTaskNode
@@ -120,7 +120,7 @@ export abstract class TaskNode<T extends Task = Task> {
     const inputVersion = task.getInputVersion()
 
     task.log.silly({
-      msg: `Completing node ${chalk.underline(this.getKey())}. aborted=${aborted}, error=${
+      msg: `Completing node ${styles.underline(this.getKey())}. aborted=${aborted}, error=${
         error ? error.message : null
       }`,
       metadata: metadataForLog(task, error ? "error" : "success", inputVersion),
@@ -269,7 +269,7 @@ export class ProcessTaskNode<T extends Task = Task> extends TaskNode<T> {
   }
 
   async execute() {
-    this.task.log.silly(`Executing node ${chalk.underline(this.getKey())}`)
+    this.task.log.silly(`Executing node ${styles.underline(this.getKey())}`)
 
     const statusTask = this.getNode("status", this.task)
     // TODO: make this more type-safe
@@ -323,7 +323,7 @@ export class StatusTaskNode<T extends Task = Task> extends TaskNode<T> {
   }
 
   async execute() {
-    this.task.log.silly(`Executing node ${chalk.underline(this.getKey())}`)
+    this.task.log.silly(`Executing node ${styles.underline(this.getKey())}`)
     const dependencyResults = this.getDependencyResults()
 
     try {
diff --git a/core/src/graph/solver.ts b/core/src/graph/solver.ts
index 38c5e5d11e..42eca6e973 100644
--- a/core/src/graph/solver.ts
+++ b/core/src/graph/solver.ts
@@ -21,12 +21,12 @@ import { gardenEnv } from "../constants.js"
 import type { Garden } from "../garden.js"
 import type { GraphResultEventPayload } from "../events/events.js"
 import { renderDivider, renderDuration, renderMessageWithDivider } from "../logger/util.js"
-import chalk from "chalk"
 import type { CompleteTaskParams, InternalNodeTypes, TaskNode, TaskRequestParams } from "./nodes.js"
 import { getNodeKey, ProcessTaskNode, RequestTaskNode, StatusTaskNode } from "./nodes.js"
 import AsyncLock from "async-lock"
+import { styles } from "../logger/styles.js"
 
-const taskStyle = chalk.cyan.bold
+const taskStyle = styles.highlight.bold
 
 export interface SolveOpts {
   statusOnly?: boolean
@@ -506,7 +506,7 @@ export class GraphSolver extends TypedEventEmitter<SolverEvents> {
     log.error({ msg, rawMsg, error, showDuration: false })
     const divider = renderDivider()
     log.silly(
-      chalk.gray(`Full error with stack trace and wrapped errors:\n${divider}\n${error.toString(true)}\n${divider}`)
+      styles.primary(`Full error with stack trace and wrapped errors:\n${divider}\n${error.toString(true)}\n${divider}`)
     )
   }
 }
diff --git a/core/src/logger/log-entry.ts b/core/src/logger/log-entry.ts
index bb869d52d8..dbc3b08776 100644
--- a/core/src/logger/log-entry.ts
+++ b/core/src/logger/log-entry.ts
@@ -14,14 +14,13 @@ import { LogLevel } from "./logger.js"
 import type { Omit } from "../util/util.js"
 import type { Logger } from "./logger.js"
 import uniqid from "uniqid"
-import chalk from "chalk"
 import type { GardenError } from "../exceptions.js"
-import hasAnsi from "has-ansi"
 import { omitUndefined } from "../util/objects.js"
 import { renderDuration } from "./util.js"
-import { errorStyle, warningStyle } from "./renderers.js"
+import { styles } from "./styles.js"
+import { getStyle } from "./renderers.js"
 
-export type LogSymbol = keyof typeof logSymbols | "empty"
+export type LogSymbol = keyof typeof logSymbols | "empty" | "cached"
 export type TaskLogStatus = "active" | "success" | "error"
 
 export interface LogMetadata {
@@ -303,10 +302,7 @@ export abstract class Log<C extends BaseContext = LogContext> implements LogConf
     // log line in question).
     const showDuration = params.showDuration !== undefined ? params.showDuration : this.showDuration
     if (showDuration && params.msg) {
-      const styleFn =
-        params.level === LogLevel.error ? errorStyle : params.level === LogLevel.warn ? warningStyle : chalk.green
-      const msg = hasAnsi(params.msg) ? params.msg : styleFn(params.msg)
-      return msg + " " + chalk.white(renderDuration(this.getDuration(1)))
+      return `${params.msg} ${renderDuration(this.getDuration(1))}`
     }
 
     return params.msg
@@ -369,6 +365,7 @@ export abstract class Log<C extends BaseContext = LogContext> implements LogConf
       ...this.resolveCreateParams(LogLevel.error, params),
       symbol: "error" as LogSymbol,
     }
+
     return this.log({
       ...resolved,
       msg: this.getMsgWithDuration(resolved),
@@ -387,11 +384,11 @@ export abstract class Log<C extends BaseContext = LogContext> implements LogConf
       ...this.resolveCreateParams(LogLevel.info, params),
       symbol: "success" as LogSymbol,
     }
-    const msgWithDuration = this.getMsgWithDuration(resolved)
-    const msg = hasAnsi(msgWithDuration || "") ? msgWithDuration : chalk.green(msgWithDuration)
+
+    const style = resolved.level === LogLevel.info ? styles.success : getStyle(resolved.level)
     return this.log({
       ...resolved,
-      msg,
+      msg: style(this.getMsgWithDuration(resolved) || ""),
     })
   }
 
diff --git a/core/src/logger/logger.ts b/core/src/logger/logger.ts
index c2baaef236..f6f42299a3 100644
--- a/core/src/logger/logger.ts
+++ b/core/src/logger/logger.ts
@@ -206,7 +206,7 @@ interface LoggerInitParams extends LoggerConfigBase {
  * Other notes:
  *   - The Log instances may apply some styling depending on the context. In general you should
  *     not have to overwrite this and simply default to calling e.g. log.warn("oh noes")
- *     as opposed to log.warn({ msg: chalk.yellow("oh noes"), symbol: "warning" })
+ *     as opposed to log.warn({ msg: styles.warning("oh noes"), symbol: "warning" })
  *   - A Log instance contains all it's parent Log configs so conceptually we can rebuild
  *     the entire log graph, e.g. for testing. We're not using this as of writing.
  */
diff --git a/core/src/logger/renderers.ts b/core/src/logger/renderers.ts
index 24e47f3903..9675ac949b 100644
--- a/core/src/logger/renderers.ts
+++ b/core/src/logger/renderers.ts
@@ -7,7 +7,6 @@
  */
 
 import logSymbols from "log-symbols"
-import chalk from "chalk"
 import stringify from "json-stringify-safe"
 import stripAnsi from "strip-ansi"
 import { isArray, repeat, trim } from "lodash-es"
@@ -19,11 +18,11 @@ import { highlightYaml, safeDumpYaml } from "../util/serialization.js"
 import type { Logger } from "./logger.js"
 import { logLevelMap, LogLevel } from "./logger.js"
 import { toGardenError } from "../exceptions.js"
+import type { Styles } from "./styles.js"
+import { styles } from "./styles.js"
 
 type RenderFn = (entry: LogEntry, logger: Logger) => string
 
-/*** STYLE HELPERS ***/
-
 export const SECTION_PADDING = 20
 
 export function padSection(section: string, width: number = SECTION_PADDING) {
@@ -31,12 +30,6 @@ export function padSection(section: string, width: number = SECTION_PADDING) {
   return diff <= 0 ? section : section + repeat(" ", diff)
 }
 
-export const msgStyle = (s: string) => chalk.gray(s)
-export const errorStyle = (s: string) => chalk.red(s)
-export const warningStyle = (s: string) => chalk.yellow(s)
-
-/*** RENDER HELPERS ***/
-
 /**
  * Combines the render functions and returns a string with the output value
  */
@@ -56,7 +49,7 @@ export function renderError(entry: LogEntry): string {
     const noAnsiMsg = stripAnsi(msg || "")
     // render error only if message doesn't already contain it
     if (!noAnsiMsg?.includes(trim(noAnsiErr, "\n"))) {
-      out = "\n\n" + chalk.red(error.message)
+      out = "\n\n" + styles.error(error.message)
     }
   }
 
@@ -76,6 +69,11 @@ export function renderSymbol(entry: LogEntry): string {
     return "  "
   }
 
+  if (symbol === "cached") {
+    return styles.highlightSecondary.bold("🞦 ")
+    // return styles.highlightSecondary.bold("🌸")
+  }
+
   // Always show symbol with sections
   if (!symbol && section) {
     symbol = "info"
@@ -89,9 +87,23 @@ export function renderTimestamp(entry: LogEntry, logger: Logger): string {
     return ""
   }
   const formattedDate = format(new Date(entry.timestamp), "HH:mm:ss")
-  return chalk.gray(formattedDate) + " "
+  return styles.secondary(formattedDate) + " "
 }
 
+export function getStyle(level: LogLevel) {
+  let style: Styles
+  if (level === LogLevel.error) {
+    style = styles.error
+  } else if (level === LogLevel.warn) {
+    style = styles.warning
+  } else if (level === LogLevel.info) {
+    style = styles.primary
+  } else {
+    style = styles.secondary
+  }
+
+  return style
+}
 export function getSection(entry: LogEntry): string | null {
   if (entry.context.type === "actionLog") {
     return `${entry.context.actionKind.toLowerCase()}.${entry.context.actionName}`
@@ -102,16 +114,20 @@ export function getSection(entry: LogEntry): string | null {
 }
 
 export function renderMsg(entry: LogEntry): string {
-  const { level, msg, context } = entry
+  const { context, level, msg } = entry
   const { origin } = context
+  const style = getStyle(level)
 
   if (!msg) {
     return ""
   }
 
-  const styleFn = level === LogLevel.error ? errorStyle : level === LogLevel.warn ? warningStyle : msgStyle
+  // TODO: @eysi Should we strip here?
+  // if (level > LogLevel.info) {
+  //   msg = stripAnsi(msg)
+  // }
 
-  return styleFn(origin ? chalk.gray(`[${origin}] ${msg}`) : msg)
+  return style(origin ? `[${styles.italic(origin)}] ` + msg : msg)
 }
 
 export function renderData(entry: LogEntry): string {
@@ -127,14 +143,13 @@ export function renderData(entry: LogEntry): string {
 }
 
 export function renderSection(entry: LogEntry): string {
-  const style = chalk.cyan.italic
   const { msg } = entry
   let section = getSection(entry)
 
   // For log levels higher than "info" we print the log level name.
   // This should technically happen when we render the symbol but it's harder
   // to deal with the padding that way.
-  const logLevelName = chalk.gray(`[${logLevelMap[entry.level]}]`)
+  const logLevelName = styles.secondary(`[${logLevelMap[entry.level]}]`)
 
   // Just print the log level name directly without padding. E.g:
   // β„Ή api                       β†’ Deploying version v-37d6c44559...
@@ -151,9 +166,9 @@ export function renderSection(entry: LogEntry): string {
   }
 
   if (section && msg) {
-    return `${style(padSection(section))} β†’ `
+    return `${styles.section(padSection(section))} ${styles.accent.bold("β†’")} `
   } else if (section) {
-    return style(padSection(section))
+    return styles.section(padSection(section))
   }
   return ""
 }
diff --git a/core/src/logger/styles.ts b/core/src/logger/styles.ts
new file mode 100644
index 0000000000..74d299613e
--- /dev/null
+++ b/core/src/logger/styles.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2018-2023 Garden Technologies, Inc. <info@garden.io>
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import chalk from "chalk"
+
+// Helper types for ensuring the consumer of the "styles" map defined below
+// can only call the allowed keys when chaining styles.
+// Otherwise you could do something like `styles.primary.red` and "break out"
+// of the pre-defined styles.
+//
+// Requires and ugly cast in the maps below but I couldn't find a more elegant
+// way to do this with just Typescript.
+type ThemeKey =
+  | "primary"
+  | "secondary"
+  | "accent"
+  | "highlight"
+  | "highlightSecondary"
+  | "warning"
+  | "error"
+  | "success"
+type StyleKey = "bold" | "underline" | "italic" | "link" | "section" | "command"
+type StyleFn = (s: string) => string
+
+export type Styles = StyleFn & { [key in ThemeKey | StyleKey]: Styles }
+
+/**
+ * A map of all the colors we use to render text in the terminal.
+ */
+const theme = {
+  primary: chalk.grey as unknown as Styles,
+  secondary: chalk.grey as unknown as Styles,
+  accent: chalk.white as unknown as Styles,
+  highlight: chalk.cyan as unknown as Styles,
+  highlightSecondary: chalk.magenta as unknown as Styles,
+  warning: chalk.yellow as unknown as Styles,
+  error: chalk.red as unknown as Styles,
+  success: chalk.green as unknown as Styles,
+}
+
+/**
+ * A map of all the styles we use to render text in the terminal.
+ *
+ * This should always be preferred over Chalk to ensure consistency
+ * and make it easy to update styles in a single place.
+ *
+ * To keep things simple, the map contains:
+ *  - color styles such as "primary"
+ *  - text styles such "italic"
+ *  - element styles such as "link".
+ *
+ * ...all of which can be accessed by calling this map.
+ *
+ * NOTE: In most cases you don't need to apply these styles and can just call
+ * the logger directly. For example, you should call `log.warn("oh no")`
+ * instead of `log.warn(styles.warning("oh no"))` since the logger applies the
+ * warning styles for you.
+ */
+export const styles = {
+  ...theme,
+  bold: chalk.bold as unknown as Styles,
+  underline: chalk.underline as unknown as Styles,
+  italic: chalk.italic as unknown as Styles,
+  link: theme.highlight.underline as unknown as Styles,
+  section: theme.highlight.italic as unknown as Styles,
+  command: theme.highlightSecondary.bold as unknown as Styles,
+}
diff --git a/core/src/logger/util.ts b/core/src/logger/util.ts
index b0d39f2599..7566279906 100644
--- a/core/src/logger/util.ts
+++ b/core/src/logger/util.ts
@@ -6,12 +6,12 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import type { Chalk } from "chalk"
-import chalk from "chalk"
 import hasAnsi from "has-ansi"
 import dedent from "dedent"
 import stringWidth from "string-width"
 import { DEFAULT_BROWSER_DIVIDER_WIDTH } from "../constants.js"
+import type { Styles } from "./styles.js"
+import { styles } from "./styles.js"
 
 // Add platforms/terminals?
 export function envSupportsEmoji() {
@@ -57,24 +57,24 @@ export function printEmoji(emoji: string, log: any) {
 }
 
 export function printHeader(log: any, command: string, emoji: string): void {
-  log.info(chalk.bold.magenta(command) + " " + printEmoji(emoji, log))
+  log.info(styles.command(command) + " " + printEmoji(emoji, log))
   log.info("") // Print new line after header
 }
 
 export function printFooter(log: any) {
   log.info("") // Print new line before footer
-  return log.info(chalk.bold.magenta("Done!") + " " + printEmoji("βœ”οΈ", log))
+  return log.info(styles.command("Done!") + " " + printEmoji("βœ”οΈ", log))
 }
 
 export function printWarningMessage(log: any, text: string) {
-  return log.warn(chalk.bold.yellow(text))
+  return log.warn(styles.bold(text))
 }
 
 interface DividerOpts {
   width?: number
   char?: string
   titlePadding?: number
-  color?: Chalk
+  color?: Styles
   title?: string
   padding?: number
 }
@@ -97,7 +97,7 @@ export function renderDivider({
   }
 
   if (!color) {
-    color = chalk.white
+    color = styles.accent
   }
 
   const titleString = title ? `${pad.repeat(titlePadding) + title + pad.repeat(titlePadding)}` : ""
@@ -144,10 +144,10 @@ export function renderMessageWithDivider({
   prefix: string
   msg: string
   isError: boolean
-  color?: Chalk
+  color?: Styles
 }) {
   // Allow overwriting color as an escape hatch. Otherwise defaults to white or red in case of errors.
-  const msgColor = color || (isError ? chalk.red : chalk.white)
+  const msgColor = color || (isError ? styles.error : styles.accent)
   const terminalDivider = msgColor.bold(renderDivider())
   const browserDivider = msgColor.bold(renderDivider({ width: DEFAULT_BROWSER_DIVIDER_WIDTH }))
   const dividerOpts = {
diff --git a/core/src/monitors/logs.ts b/core/src/monitors/logs.ts
index 88142d08c8..0abb897f52 100644
--- a/core/src/monitors/logs.ts
+++ b/core/src/monitors/logs.ts
@@ -22,6 +22,7 @@ import { waitForOutputFlush } from "../process.js"
 import type { DeployLogEntry } from "../types/service.js"
 import type { MonitorBaseParams } from "./base.js"
 import { Monitor } from "./base.js"
+import { styles } from "../logger/styles.js"
 
 export const logMonitorColors = ["green", "cyan", "magenta", "yellow", "blueBright", "blue"]
 
@@ -232,13 +233,13 @@ export class LogMonitor extends Monitor {
       out += `${sectionStyle(padSection(entry.name, maxDeployName))} β†’ `
     }
     if (timestamp) {
-      out += `${chalk.gray(timestamp)} β†’ `
+      out += `${styles.primary(timestamp)} β†’ `
     }
     if (tags) {
-      out += chalk.gray("[" + tags + "] ")
+      out += styles.primary("[" + tags + "] ")
     }
     // If the line doesn't have ansi encoding, we color it white to prevent logger from applying styles.
-    out += hasAnsi(serviceLog) ? serviceLog : chalk.white(serviceLog)
+    out += hasAnsi(serviceLog) ? serviceLog : styles.accent(serviceLog)
 
     return out
   }
diff --git a/core/src/monitors/manager.ts b/core/src/monitors/manager.ts
index c94dccd9f8..d6435a313f 100644
--- a/core/src/monitors/manager.ts
+++ b/core/src/monitors/manager.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type { Command } from "../commands/base.js"
 import type { EventBus } from "../events/events.js"
 import type { Log } from "../logger/log-entry.js"
@@ -137,7 +136,7 @@ export class MonitorManager extends TypedEventEmitter<MonitorEvents> {
         }
       })
       .catch((error) => {
-        this.log.error({ msg: chalk.red(`${monitor.description()} failed: ${error}`), error })
+        this.log.error({ msg: `${monitor.description()} failed: ${error}`, error })
         this.setStatus(monitor, "stopped")
         // TODO: should we retry up to some limit?
       })
@@ -168,7 +167,7 @@ export class MonitorManager extends TypedEventEmitter<MonitorEvents> {
         }
       })
       .catch((error) => {
-        log.error(chalk.red(`Error when stopping ${monitor.description()}: ${error}`))
+        log.error(`Error when stopping ${monitor.description()}: ${error}`)
         this.setStatus(monitor, "stopped")
       })
   }
diff --git a/core/src/monitors/port-forward.ts b/core/src/monitors/port-forward.ts
index 7f231a2cff..1891533cd2 100644
--- a/core/src/monitors/port-forward.ts
+++ b/core/src/monitors/port-forward.ts
@@ -15,7 +15,7 @@ import type { MonitorBaseParams } from "./base.js"
 import { Monitor } from "./base.js"
 import type { PortProxy } from "../proxy.js"
 import { startPortProxies, stopPortProxy } from "../proxy.js"
-import chalk from "chalk"
+import { styles } from "../logger/styles.js"
 
 interface PortForwardMonitorParams extends MonitorBaseParams {
   action: Executed<DeployAction>
@@ -84,9 +84,9 @@ export class PortForwardMonitor extends Monitor {
       const targetHost = proxy.spec.targetName || this.action.name
 
       this.log.info(
-        chalk.gray(
+        styles.primary(
           `Port forward: ` +
-            chalk.underline(proxy.localUrl) +
+            styles.underline(proxy.localUrl) +
             ` β†’ ${targetHost}:${proxy.spec.targetPort}` +
             (proxy.spec.name ? ` (${proxy.spec.name})` : "")
         )
diff --git a/core/src/mutagen.ts b/core/src/mutagen.ts
index 8fe5fbc6f5..9329820ca4 100644
--- a/core/src/mutagen.ts
+++ b/core/src/mutagen.ts
@@ -7,7 +7,6 @@
  */
 
 import AsyncLock from "async-lock"
-import chalk from "chalk"
 import dedent from "dedent"
 import type EventEmitter from "events"
 import type { ExecaReturnValue } from "execa"
@@ -31,11 +30,11 @@ import { deline } from "./util/string.js"
 import { registerCleanupFunction, sleep } from "./util/util.js"
 import { emitNonRepeatableWarning } from "./warnings.js"
 import type { OctalPermissionMask } from "./plugins/kubernetes/types.js"
+import { styles } from "./logger/styles.js"
 
 const maxRestarts = 10
 const mutagenLogSection = "<mutagen>"
 const crashMessage = `Synchronization monitor has crashed ${maxRestarts} times. Aborting.`
-const syncLogPrefix = "[sync]:"
 
 export const mutagenAgentPath = "/.garden/mutagen-agent"
 
@@ -228,7 +227,7 @@ class _MutagenMonitor extends TypedEventEmitter<MonitorEvents> {
       this.proc = proc
 
       proc.on("crash", () => {
-        log.warn(chalk.yellow(crashMessage))
+        log.warn(crashMessage)
       })
 
       proc.on("exit", (code: number) => {
@@ -241,7 +240,7 @@ class _MutagenMonitor extends TypedEventEmitter<MonitorEvents> {
         const str = data.toString().trim()
         // This is a little dumb, to detect if the log line starts with a timestamp, but ya know...
         // it'll basically work for the next 979 years :P.
-        const msg = chalk.gray(str.startsWith("2") ? str.split(" ").slice(3).join(" ") : str)
+        const msg = styles.primary(str.startsWith("2") ? str.split(" ").slice(3).join(" ") : str)
         if (msg.includes("Unable") && lastDaemonError !== msg) {
           log.warn(msg)
           // Make sure we don't spam with repeated messages
@@ -280,7 +279,7 @@ class _MutagenMonitor extends TypedEventEmitter<MonitorEvents> {
           if (resolved) {
             log.debug({
               symbol: "empty",
-              msg: chalk.green("Mutagen monitor re-started"),
+              msg: "Mutagen monitor re-started",
             })
           }
         })
@@ -364,7 +363,7 @@ export class Mutagen {
       ]
 
       const { logSection: section } = activeSync
-      const syncLog = this.log.createLog({ name: section })
+      const syncLog = this.log.createLog({ name: section, origin: "sync" })
 
       for (const problem of problems) {
         if (!activeSync.lastProblems.includes(problem)) {
@@ -395,7 +394,7 @@ export class Mutagen {
       if (syncCount > activeSync.lastSyncCount && !activeSync.initialSyncComplete) {
         syncLog.info({
           symbol: "success",
-          msg: chalk.white(`${syncLogPrefix} Completed initial sync ${description}`),
+          msg: `Completed initial sync ${description}`,
         })
         activeSync.initialSyncComplete = true
       }
@@ -416,7 +415,7 @@ export class Mutagen {
       }
 
       if (statusMsg) {
-        syncLog.info(`${syncLogPrefix} ${statusMsg}`)
+        syncLog.info(statusMsg)
         activeSync.lastStatusMsg = statusMsg
       }
 
@@ -553,7 +552,7 @@ export class Mutagen {
         const unableToFlush = err.message.match(/unable to flush session/)
         if (unableToFlush) {
           this.log.warn(
-            chalk.gray(
+            styles.primary(
               `Could not flush synchronization changes, retrying (attempt ${err.attemptNumber}/${err.retriesLeft})...`
             )
           )
@@ -576,7 +575,7 @@ export class Mutagen {
         try {
           await this.flushSync(session.name)
         } catch (err) {
-          log.warn(chalk.yellow(`Failed to flush sync '${session.name}: ${err}`))
+          log.warn(`Failed to flush sync '${session.name}: ${err}`)
         }
       })
     )
@@ -647,7 +646,9 @@ export class Mutagen {
         const unableToConnect = err.message.match(/unable to connect to daemon/)
         if (unableToConnect && loops < 10) {
           loops += 1
-          this.log.warn(chalk.gray(`Could not connect to sync daemon, retrying (attempt ${loops}/${maxRetries})...`))
+          this.log.warn(
+            styles.primary(`Could not connect to sync daemon, retrying (attempt ${loops}/${maxRetries})...`)
+          )
           await this.ensureDaemon()
           await sleep(2000 + loops * 500)
         } else {
@@ -922,17 +923,17 @@ async function isValidLocalPath(syncPoint: string) {
 
 function formatSyncConflict(sourceDescription: string, targetDescription: string, conflict: SyncConflict): string {
   return dedent`
-    Sync conflict detected at path ${chalk.white(
+    Sync conflict detected at path ${styles.accent(
       conflict.root
     )} in sync from ${sourceDescription} to ${targetDescription}.
 
     Until the conflict is resolved, the conflicting paths will not be synced.
 
-    If conflicts come up regularly at this destination, you may want to use either the ${chalk.white(
+    If conflicts come up regularly at this destination, you may want to use either the ${styles.accent(
       "one-way-replica"
-    )} or ${chalk.white("one-way-replica-reverse")} sync modes instead.
+    )} or ${styles.accent("one-way-replica-reverse")} sync modes instead.
 
-    See the code synchronization guide for more details: ${chalk.white(syncGuideLink + "#sync-modes")}`
+    See the code synchronization guide for more details: ${styles.accent(syncGuideLink + "#sync-modes")}`
 }
 
 /**
diff --git a/core/src/plugin/sdk.ts b/core/src/plugin/sdk.ts
index 4ce3a64bc2..3603ae243a 100644
--- a/core/src/plugin/sdk.ts
+++ b/core/src/plugin/sdk.ts
@@ -44,7 +44,6 @@ import type {
 import type { PluginToolSpec } from "./tools.js"
 import { dedent } from "../util/string.js"
 import type { BuildStatus as _BuildStatus } from "./handlers/Build/get-status.js"
-import chalk from "chalk"
 
 type ObjectBaseZod = z.ZodObject<{}>
 
@@ -398,7 +397,6 @@ export const sdk = {
   createActionType,
 
   util: {
-    chalk,
     dedent,
   },
 }
diff --git a/core/src/plugins/container/helpers.ts b/core/src/plugins/container/helpers.ts
index 81dabf1930..3323cb4b14 100644
--- a/core/src/plugins/container/helpers.ts
+++ b/core/src/plugins/container/helpers.ts
@@ -22,7 +22,6 @@ import type { Writable } from "stream"
 import { flatten, uniq, fromPairs, reduce } from "lodash-es"
 import type { ActionLog, Log } from "../../logger/log-entry.js"
 
-import chalk from "chalk"
 import isUrl from "is-url"
 import titleize from "titleize"
 import { deline, stripQuotes, splitLast, splitFirst } from "../../util/string.js"
@@ -34,6 +33,7 @@ import { defaultDockerfileName } from "./config.js"
 import { joinWithPosix } from "../../util/fs.js"
 import type { Resolved } from "../../actions/types.js"
 import pMemoize from "../../lib/p-memoize.js"
+import { styles } from "../../logger/styles.js"
 
 interface DockerVersion {
   client?: string
@@ -427,7 +427,7 @@ const helpers = {
         (cmd) => (cmd.name === "ADD" || cmd.name === "COPY") && cmd.args && Number(cmd.args.length) > 0
       )
     } catch (err) {
-      log.warn(chalk.yellow(`Unable to parse Dockerfile ${dockerfilePath}: ${err}`))
+      log.warn(`Unable to parse Dockerfile ${dockerfilePath}: ${err}`)
       return undefined
     }
 
@@ -473,12 +473,10 @@ const helpers = {
       } else if (path.match(/(?<!\\)(?:\\\\)*\$[{\w]/)) {
         // If the path contains a template string we can't currently reason about it
         // TODO: interpolate args into paths
-        log.warn(
-          chalk.yellow(deline`
+        log.warn(deline`
           Resolving include paths from Dockerfile ARG and ENV variables is not supported yet. Please specify
-          required path in Dockerfile explicitly or use ${chalk.bold("include")} for path assigned to ARG or ENV.
-          `)
-        )
+          required path in Dockerfile explicitly or use ${styles.bold("include")} for path assigned to ARG or ENV.
+        `)
         return undefined
       }
     }
diff --git a/core/src/plugins/exec/build.ts b/core/src/plugins/exec/build.ts
index f746f39291..2718139957 100644
--- a/core/src/plugins/exec/build.ts
+++ b/core/src/plugins/exec/build.ts
@@ -13,6 +13,7 @@ import type {
   BuildStatus,
 } from "../../plugin/sdk.js"
 import { sdk } from "../../plugin/sdk.js"
+import { styles } from "../../logger/styles.js"
 import { execRunCommand } from "./common.js"
 import { execCommonSchema, execEnvVarDoc, execRuntimeOutputsSchema, execStaticOutputsSchema } from "./config.js"
 import { execProvider } from "./exec.js"
@@ -54,7 +55,6 @@ export const execBuildHandler = execBuild.addHandler("build", async ({ action, l
   const output: BuildStatus = { state: "ready", outputs: {}, detail: {} }
   const command = action.getSpec("command")
 
-  const { chalk } = sdk.util
   let success = true
 
   if (command?.length) {
@@ -72,13 +72,13 @@ export const execBuildHandler = execBuild.addHandler("build", async ({ action, l
   if (output.detail?.buildLog) {
     output.outputs.log = output.detail?.buildLog
 
-    const prefix = `Finished building ${chalk.white(action.name)}. Here is the full output:`
+    const prefix = `Finished building ${styles.accent(action.name)}. Here is the full output:`
     log.info(
       renderMessageWithDivider({
         prefix,
         msg: output.detail?.buildLog,
         isError: !success,
-        color: chalk.gray,
+        color: styles.primary,
       })
     )
   }
diff --git a/core/src/plugins/exec/deploy.ts b/core/src/plugins/exec/deploy.ts
index 7efb1d6fb6..13bed9d268 100644
--- a/core/src/plugins/exec/deploy.ts
+++ b/core/src/plugins/exec/deploy.ts
@@ -17,7 +17,6 @@ import { TimeoutError } from "../../exceptions.js"
 import type { Log } from "../../logger/log-entry.js"
 import type { ExecaReturnBase } from "execa"
 import { execa } from "execa"
-import chalk from "chalk"
 import { renderMessageWithDivider } from "../../logger/util.js"
 import { LogLevel } from "../../logger/logger.js"
 import { createWriteStream } from "fs"
@@ -43,6 +42,7 @@ import { sdk } from "../../plugin/sdk.js"
 import { execProvider } from "./exec.js"
 import { getTracePropagationEnvVars } from "../../util/open-telemetry/propagation.js"
 import type { DeployState } from "../../types/service.js"
+import { styles } from "../../logger/styles.js"
 
 const persistentLocalProcRetryIntervalMs = 2500
 
@@ -200,13 +200,13 @@ execDeploy.addHandler("deploy", async (params) => {
     })
 
     if (result.outputLog) {
-      const prefix = `Finished deploying ${chalk.white(action.name)}. Here is the output:`
+      const prefix = `Finished deploying ${styles.accent(action.name)}. Here is the output:`
       log.info(
         renderMessageWithDivider({
           prefix,
           msg: result.outputLog,
           isError: !result.success,
-          color: chalk.gray,
+          color: styles.primary,
         })
       )
     }
@@ -290,14 +290,16 @@ export async function deployPersistentExecService({
         throw new TimeoutError({
           message: dedent`Timed out waiting for local service ${deployName} to be ready.
 
-          Garden timed out waiting for the command ${chalk.gray(spec.statusCommand)} (pid: ${proc.pid})
+          Garden timed out waiting for the command ${styles.primary(spec.statusCommand.join(" "))} (pid: ${proc.pid})
           to return status code 0 (success) after waiting for ${spec.statusTimeout} seconds.
           ${lastResultDescription}
           Possible next steps:
 
           Find out why the configured status command fails.
 
-          In case the service just needs more time to become ready, you can adjust the ${chalk.gray("timeout")} value
+          In case the service just needs more time to become ready, you can adjust the ${styles.primary(
+            "timeout"
+          )} value
           in your service definition to a value that is greater than the time needed for your service to become ready.
           `,
         })
diff --git a/core/src/plugins/exec/run.ts b/core/src/plugins/exec/run.ts
index 190cf5d003..ac9e4f1c00 100644
--- a/core/src/plugins/exec/run.ts
+++ b/core/src/plugins/exec/run.ts
@@ -10,6 +10,7 @@ import { runResultToActionState } from "../../actions/base.js"
 import { renderMessageWithDivider } from "../../logger/util.js"
 import type { GardenSdkActionDefinitionActionType, GardenSdkActionDefinitionConfigType } from "../../plugin/sdk.js"
 import { sdk } from "../../plugin/sdk.js"
+import { styles } from "../../logger/styles.js"
 import { copyArtifacts, execRunCommand } from "./common.js"
 import { execRunSpecSchema, execRuntimeOutputsSchema, execStaticOutputsSchema } from "./config.js"
 import { execProvider } from "./exec.js"
@@ -47,16 +48,14 @@ execRun.addHandler("run", async ({ artifactsPath, log, action, ctx }) => {
     outputLog = ""
   }
 
-  const { chalk } = sdk.util
-
   if (outputLog) {
-    const prefix = `Finished running ${chalk.white(action.name)}. Here is the full output:`
+    const prefix = `Finished running ${styles.accent(action.name)}. Here is the full output:`
     log.info(
       renderMessageWithDivider({
         prefix,
         msg: outputLog,
         isError: !success,
-        color: chalk.gray,
+        color: styles.primary,
       })
     )
   }
diff --git a/core/src/plugins/exec/test.ts b/core/src/plugins/exec/test.ts
index b1af2ce9fb..96e9b8f382 100644
--- a/core/src/plugins/exec/test.ts
+++ b/core/src/plugins/exec/test.ts
@@ -10,6 +10,7 @@ import { runResultToActionState } from "../../actions/base.js"
 import { renderMessageWithDivider } from "../../logger/util.js"
 import type { GardenSdkActionDefinitionActionType, GardenSdkActionDefinitionConfigType } from "../../plugin/sdk.js"
 import { sdk } from "../../plugin/sdk.js"
+import { styles } from "../../logger/styles.js"
 import { copyArtifacts, execRunCommand } from "./common.js"
 import { execRunSpecSchema, execRuntimeOutputsSchema, execStaticOutputsSchema } from "./config.js"
 import { execProvider } from "./exec.js"
@@ -39,16 +40,14 @@ execTest.addHandler("run", async ({ log, action, artifactsPath, ctx }) => {
   const artifacts = action.getSpec("artifacts")
   await copyArtifacts(log, artifacts, action.getBuildPath(), artifactsPath)
 
-  const { chalk } = sdk.util
-
   if (result.outputLog) {
-    const prefix = `Finished executing ${chalk.white(action.key())}. Here is the full output:`
+    const prefix = `Finished executing ${styles.accent(action.key())}. Here is the full output:`
     log.info(
       renderMessageWithDivider({
         prefix,
         msg: result.outputLog,
         isError: !result.success,
-        color: chalk.gray,
+        color: styles.primary,
       })
     )
   }
diff --git a/core/src/plugins/hadolint/hadolint.ts b/core/src/plugins/hadolint/hadolint.ts
index 8e318e2d6f..16e5def1f1 100644
--- a/core/src/plugins/hadolint/hadolint.ts
+++ b/core/src/plugins/hadolint/hadolint.ts
@@ -13,7 +13,6 @@ import { joi } from "../../config/common.js"
 import { dedent, splitLines, naturalList } from "../../util/string.js"
 import { STATIC_DIR } from "../../constants.js"
 import { padStart, padEnd } from "lodash-es"
-import chalk from "chalk"
 import { ConfigurationError, GardenError } from "../../exceptions.js"
 import { defaultDockerfileName } from "../container/config.js"
 import { baseBuildSpecSchema } from "../../config/module.js"
@@ -23,6 +22,7 @@ import { mayContainTemplateString } from "../../template-string/template-string.
 import type { BaseAction } from "../../actions/base.js"
 import type { BuildAction } from "../../actions/build.js"
 import { sdk } from "../../plugin/sdk.js"
+import { styles } from "../../logger/styles.js"
 
 const defaultConfigPath = join(STATIC_DIR, "hadolint", "default.hadolint.yaml")
 const configFilename = ".hadolint.yaml"
@@ -343,15 +343,15 @@ hadolintTest.addHandler("run", async ({ ctx, log, action }) => {
       `${formattedHeader}:\n\n` +
       parsed
         .map((msg: any) => {
-          const color = msg.level === "error" ? chalk.bold.red : chalk.bold.yellow
+          const color = msg.level === "error" ? styles.error.bold : styles.warning.bold
           const rawLine = dockerfileLines[msg.line - 1]
           const linePrefix = padEnd(`${msg.line}:`, 5, " ")
           const columnCursorPosition = (msg.column || 1) + linePrefix.length
 
           return dedent`
-          ${color(msg.code + ":")} ${chalk.bold(msg.message || "")}
-          ${linePrefix}${chalk.gray(rawLine)}
-          ${chalk.gray(padStart("^", columnCursorPosition, "-"))}
+          ${color(msg.code + ":")} ${styles.bold(msg.message || "")}
+          ${linePrefix}${styles.primary(rawLine)}
+          ${styles.primary(padStart("^", columnCursorPosition, "-"))}
         `
         })
         .join("\n")
@@ -364,7 +364,7 @@ hadolintTest.addHandler("run", async ({ ctx, log, action }) => {
   } else if (errors.length > 0 && threshold !== "none") {
     success = false
   } else if (warnings.length > 0) {
-    log.warn(chalk.yellow(formattedHeader))
+    log.warn(formattedHeader)
   }
 
   return {
diff --git a/core/src/plugins/kubernetes/commands/cluster-init.ts b/core/src/plugins/kubernetes/commands/cluster-init.ts
index 5c575ec669..717706eaf7 100644
--- a/core/src/plugins/kubernetes/commands/cluster-init.ts
+++ b/core/src/plugins/kubernetes/commands/cluster-init.ts
@@ -8,8 +8,8 @@
 
 import type { PluginCommand } from "../../../plugin/command.js"
 import { prepareEnvironment, getEnvironmentStatus } from "../init.js"
-import chalk from "chalk"
 import { emitNonRepeatableWarning } from "../../../warnings.js"
+import { styles } from "../../../logger/styles.js"
 
 // TODO: remove in 0.14
 export const clusterInit: PluginCommand = {
@@ -17,7 +17,7 @@ export const clusterInit: PluginCommand = {
   description: "[DEPRECATED] Initialize or update cluster-wide Garden services.",
 
   title: ({ environmentName }) => {
-    return `Initializing/updating cluster-wide services for ${chalk.white(environmentName)} environment`
+    return `Initializing/updating cluster-wide services for ${styles.accent(environmentName)} environment`
   },
 
   handler: async ({ ctx, log }) => {
@@ -37,7 +37,7 @@ export const clusterInit: PluginCommand = {
       })
     }
 
-    log.info(chalk.green("\nDone!"))
+    log.success({ msg: "\nDone!", showDuration: false })
 
     return { result }
   },
diff --git a/core/src/plugins/kubernetes/commands/pull-image.ts b/core/src/plugins/kubernetes/commands/pull-image.ts
index 61899033e8..7e925755b1 100644
--- a/core/src/plugins/kubernetes/commands/pull-image.ts
+++ b/core/src/plugins/kubernetes/commands/pull-image.ts
@@ -11,7 +11,6 @@ import tmp from "tmp-promise"
 import type { KubernetesPluginContext } from "../config.js"
 import { PluginError, ParameterError, GardenError } from "../../../exceptions.js"
 import type { PluginCommand } from "../../../plugin/command.js"
-import chalk from "chalk"
 import { KubeApi } from "../api.js"
 import type { Log } from "../../../logger/log-entry.js"
 import { containerHelpers } from "../../container/helpers.js"
@@ -26,6 +25,7 @@ import type { ContainerBuildAction } from "../../container/config.js"
 import { k8sGetContainerBuildActionOutputs } from "../container/handlers.js"
 import type { Resolved } from "../../../actions/types.js"
 import { finished } from "node:stream/promises"
+import { styles } from "../../../logger/styles.js"
 
 const tmpTarPath = "/tmp/image.tar"
 const imagePullTimeoutSeconds = 60 * 20
@@ -51,13 +51,13 @@ export const pullImage: PluginCommand = {
       const valid = b.isCompatible("container")
       if (!valid && args.includes(b.name)) {
         throw new ParameterError({
-          message: chalk.red(`Build ${chalk.white(b.name)} is not a container build.`),
+          message: `Build ${styles.accent(b.name)} is not a container build.`,
         })
       }
       return valid
     })
 
-    log.info({ msg: chalk.cyan(`\nPulling images for ${buildsToPull.length} builds`) })
+    log.info({ msg: styles.highlight(`\nPulling images for ${buildsToPull.length} builds`) })
 
     const resolvedBuilds = await garden.resolveActions({ actions: buildsToPull, graph, log })
 
@@ -75,9 +75,9 @@ async function pullBuilds(ctx: KubernetesPluginContext, builds: Resolved<Contain
       const outputs = k8sGetContainerBuildActionOutputs({ provider: ctx.provider, action })
       const remoteId = action.getSpec("publishId") || outputs.deploymentImageId
       const localId = outputs.localImageId
-      log.info({ msg: chalk.cyan(`Pulling image ${remoteId} to ${localId}`) })
+      log.info({ msg: styles.highlight(`Pulling image ${remoteId} to ${localId}`) })
       await pullBuild({ ctx, action, log, localId, remoteId })
-      log.info({ msg: chalk.green(`\nPulled image: ${remoteId} -> ${localId}`) })
+      log.success({ msg: styles.success(`\nPulled image: ${remoteId} -> ${localId}`), showDuration: false })
     })
   )
 }
diff --git a/core/src/plugins/kubernetes/commands/sync.ts b/core/src/plugins/kubernetes/commands/sync.ts
index 3f92003c0b..8526d871f6 100644
--- a/core/src/plugins/kubernetes/commands/sync.ts
+++ b/core/src/plugins/kubernetes/commands/sync.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { getMutagenDataDir, getMutagenEnv, mutagenCliSpec, parseSyncListResult } from "../../../mutagen.js"
 import fsExtra from "fs-extra"
 const { pathExists } = fsExtra
@@ -14,8 +13,9 @@ import { dedent } from "../../../util/string.js"
 import type { Log } from "../../../logger/log-entry.js"
 import { PluginTool } from "../../../util/ext-tools.js"
 import type { PluginCommand } from "../../../plugin/command.js"
+import { styles } from "../../../logger/styles.js"
 
-const logSuccess = (log: Log) => log.info({ msg: chalk.green("\nDone!") })
+const logSuccess = (log: Log) => log.info({ msg: styles.success("\nDone!") })
 
 export const syncStatus: PluginCommand = {
   name: "sync-status",
diff --git a/core/src/plugins/kubernetes/commands/uninstall-garden-services.ts b/core/src/plugins/kubernetes/commands/uninstall-garden-services.ts
index 99539e52d5..2012b3a656 100644
--- a/core/src/plugins/kubernetes/commands/uninstall-garden-services.ts
+++ b/core/src/plugins/kubernetes/commands/uninstall-garden-services.ts
@@ -6,17 +6,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type { PluginCommand } from "../../../plugin/command.js"
 import type { KubernetesPluginContext } from "../config.js"
 import { ingressControllerUninstall } from "../nginx/ingress-controller.js"
+import { styles } from "../../../logger/styles.js"
 
 export const uninstallGardenServices: PluginCommand = {
   name: "uninstall-garden-services",
   description: "Clean up all installed cluster-wide Garden services.",
 
   title: ({ environmentName }) => {
-    return `Removing cluster-wide services for ${chalk.white(environmentName)} environment`
+    return `Removing cluster-wide services for ${styles.accent(environmentName)} environment`
   },
 
   handler: async ({ ctx, log }) => {
@@ -26,7 +26,7 @@ export const uninstallGardenServices: PluginCommand = {
       await ingressControllerUninstall(k8sCtx, log)
     }
 
-    log.info(chalk.green("\nDone!"))
+    log.success({ msg: "\nDone!", showDuration: false })
 
     return { result: {} }
   },
diff --git a/core/src/plugins/kubernetes/container/build/buildkit.ts b/core/src/plugins/kubernetes/container/build/buildkit.ts
index b03d3d54b1..4b68d2fc7d 100644
--- a/core/src/plugins/kubernetes/container/build/buildkit.ts
+++ b/core/src/plugins/kubernetes/container/build/buildkit.ts
@@ -7,7 +7,6 @@
  */
 
 import AsyncLock from "async-lock"
-import chalk from "chalk"
 import split2 from "split2"
 import { isEmpty } from "lodash-es"
 import {
@@ -43,6 +42,7 @@ import { getRunningDeploymentPod } from "../../util.js"
 import { defaultDockerfileName } from "../../../container/config.js"
 import { k8sGetContainerBuildActionOutputs } from "../handlers.js"
 import { stringifyResources } from "../util.js"
+import { styles } from "../../../../logger/styles.js"
 
 const deployLock = new AsyncLock()
 
@@ -220,7 +220,7 @@ export async function ensureBuildkit({
 
     // Deploy the buildkit daemon
     deployLog.info(
-      chalk.gray(`-> Deploying ${buildkitDeploymentName} daemon in ${namespace} namespace (was ${status.state})`)
+      styles.primary(`-> Deploying ${buildkitDeploymentName} daemon in ${namespace} namespace (was ${status.state})`)
     )
 
     await api.upsert({ kind: "Deployment", namespace, log: deployLog, obj: manifest })
diff --git a/core/src/plugins/kubernetes/container/build/common.ts b/core/src/plugins/kubernetes/container/build/common.ts
index b7efa33188..ebd523a812 100644
--- a/core/src/plugins/kubernetes/container/build/common.ts
+++ b/core/src/plugins/kubernetes/container/build/common.ts
@@ -17,7 +17,6 @@ import { InternalError, RuntimeError } from "../../../../exceptions.js"
 import type { Log } from "../../../../logger/log-entry.js"
 import { prepareDockerAuth } from "../../init.js"
 import { prepareSecrets } from "../../secrets.js"
-import chalk from "chalk"
 import { Mutagen } from "../../../../mutagen.js"
 import { randomString } from "../../../../util/string.js"
 import type { V1Container, V1Service } from "@kubernetes/client-node"
@@ -31,6 +30,7 @@ import { stringifyResources } from "../util.js"
 import { getKubectlExecDestination } from "../../sync.js"
 import { getRunningDeploymentPod } from "../../util.js"
 import { buildSyncVolumeName, dockerAuthSecretKey, k8sUtilImageName, rsyncPortName } from "../../constants.js"
+import { styles } from "../../../../logger/styles.js"
 
 export const utilContainerName = "util"
 export const utilRsyncPort = 8730
@@ -311,7 +311,7 @@ export async function ensureUtilDeployment({
 
     // Deploy the service
     deployLog.info(
-      chalk.gray(`-> Deploying ${utilDeploymentName} service in ${namespace} namespace (was ${status.state})`)
+      styles.primary(`-> Deploying ${utilDeploymentName} service in ${namespace} namespace (was ${status.state})`)
     )
 
     await api.upsert({ kind: "Deployment", namespace, log: deployLog, obj: deployment })
@@ -378,7 +378,7 @@ export async function ensureBuilderSecret({
   const existingSecret = await api.readBySpecOrNull({ log, namespace, manifest: authSecret })
 
   if (!existingSecret || authSecret.data?.[dockerAuthSecretKey] !== existingSecret.data?.[dockerAuthSecretKey]) {
-    log.info(chalk.gray(`-> Updating Docker auth secret in namespace ${namespace}`))
+    log.info(styles.primary(`-> Updating Docker auth secret in namespace ${namespace}`))
     await api.upsert({ kind: "Secret", namespace, log, obj: authSecret })
     updated = true
   }
diff --git a/core/src/plugins/kubernetes/container/build/kaniko.ts b/core/src/plugins/kubernetes/container/build/kaniko.ts
index 05a52aaff8..f69107c4ed 100644
--- a/core/src/plugins/kubernetes/container/build/kaniko.ts
+++ b/core/src/plugins/kubernetes/container/build/kaniko.ts
@@ -36,13 +36,13 @@ import {
   utilDeploymentName,
 } from "./common.js"
 import { differenceBy, isEmpty } from "lodash-es"
-import chalk from "chalk"
 import { getDockerBuildFlags } from "../../../container/build.js"
 import { k8sGetContainerBuildActionOutputs } from "../handlers.js"
 import { stringifyResources } from "../util.js"
 import { makePodName } from "../../util.js"
 import type { ContainerBuildAction } from "../../../container/config.js"
 import { defaultDockerfileName } from "../../../container/config.js"
+import { styles } from "../../../../logger/styles.js"
 
 export const DEFAULT_KANIKO_FLAGS = ["--cache=true"]
 
@@ -174,7 +174,7 @@ export const kanikoBuild: BuildHandler = async (params) => {
 
   if (kanikoBuildFailed(buildRes)) {
     throw new BuildError({
-      message: `Failed building ${chalk.bold(action.name)}:\n\n${buildLog}`,
+      message: `Failed building ${styles.bold(action.name)}:\n\n${buildLog}`,
     })
   }
 
diff --git a/core/src/plugins/kubernetes/container/build/local.ts b/core/src/plugins/kubernetes/container/build/local.ts
index a41a736ce1..237a781ed8 100644
--- a/core/src/plugins/kubernetes/container/build/local.ts
+++ b/core/src/plugins/kubernetes/container/build/local.ts
@@ -10,7 +10,6 @@ import { containerHelpers } from "../../../container/helpers.js"
 import { buildContainer, getContainerBuildStatus } from "../../../container/build.js"
 import type { KubernetesProvider, KubernetesPluginContext } from "../../config.js"
 import { loadImageToKind, getKindImageStatus } from "../../local/kind.js"
-import chalk from "chalk"
 import { loadImageToMicrok8s, getMicrok8sImageStatus } from "../../local/microk8s.js"
 import type { ContainerProvider } from "../../../container/container.js"
 import type { BuildHandler, BuildStatusHandler, BuildStatusResult } from "./common.js"
@@ -42,7 +41,7 @@ export const getLocalBuildStatus: BuildStatusHandler = async (params) => {
     // Non-zero exit code can both mean the manifest is not found, and any other unexpected error
     if (res.code !== 0 && !res.all.includes("no such manifest")) {
       const detail = res.all || `docker manifest inspect exited with code ${res.code}`
-      log.warn(chalk.yellow(`Unable to query registry for image status: ${detail}`))
+      log.warn(`Unable to query registry for image status: ${detail}`)
     }
 
     if (res.code === 0) {
diff --git a/core/src/plugins/kubernetes/container/deployment.ts b/core/src/plugins/kubernetes/container/deployment.ts
index 2961764db8..92544f1292 100644
--- a/core/src/plugins/kubernetes/container/deployment.ts
+++ b/core/src/plugins/kubernetes/container/deployment.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type {
   V1Affinity,
   V1Container,
@@ -49,6 +48,7 @@ import type { ContainerServiceStatus } from "./status.js"
 import { k8sGetContainerDeployStatus } from "./status.js"
 import { emitNonRepeatableWarning } from "../../../warnings.js"
 import { K8_POD_DEFAULT_CONTAINER_ANNOTATION_KEY } from "../run.js"
+import { styles } from "../../../logger/styles.js"
 
 export const REVISION_HISTORY_LIMIT_PROD = 10
 export const REVISION_HISTORY_LIMIT_DEFAULT = 3
@@ -253,7 +253,7 @@ export async function createWorkloadManifest({
 
   if (mode === "local" && configuredReplicas > 1) {
     log.verbose({
-      msg: chalk.yellow(`Ignoring replicas config on container Deploy ${action.name} while in local mode`),
+      msg: styles.warning(`Ignoring replicas config on container Deploy ${action.name} while in local mode`),
       symbol: "warning",
     })
     configuredReplicas = 1
@@ -449,7 +449,7 @@ export async function createWorkloadManifest({
 
     workload = <KubernetesResource<V1Deployment | V1DaemonSet>>configured.updated[0]
   } else if (mode === "sync" && syncSpec) {
-    log.debug(chalk.gray(`-> Configuring in sync mode`))
+    log.debug(styles.primary(`-> Configuring in sync mode`))
     const configured = await configureSyncMode({
       ctx,
       log,
@@ -648,10 +648,12 @@ export function configureVolumes(
         })
       } else {
         throw new ConfigurationError({
-          message: chalk.red(deline`${action.longDescription()} specifies a unsupported config
-          ${chalk.white(volumeAction.name)} for volume mount ${chalk.white(volumeName)}. Only \`persistentvolumeclaim\`
+          message: deline`${action.longDescription()} specifies a unsupported config
+          ${styles.accent(volumeAction.name)} for volume mount ${styles.accent(
+            volumeName
+          )}. Only \`persistentvolumeclaim\`
           and \`configmap\` action are supported at this time.
-          `),
+          `,
         })
       }
     } else {
@@ -709,27 +711,23 @@ export async function handleChangedSelector({
   production: boolean
   force: boolean
 }) {
-  const msgPrefix = `Deploy ${chalk.white(action.name)} was deployed with a different ${chalk.white(
+  const msgPrefix = `Deploy ${styles.accent(action.name)} was deployed with a different ${styles.accent(
     "spec.selector"
   )} and needs to be deleted before redeploying.`
   if (production && !force) {
     throw new DeploymentError({
-      message: `${msgPrefix} Since this environment has production = true, Garden won't automatically delete this resource. To do so, use the ${chalk.white(
+      message: `${msgPrefix} Since this environment has production = true, Garden won't automatically delete this resource. To do so, use the ${styles.accent(
         "--force"
-      )} flag when deploying e.g. with the ${chalk.white(
+      )} flag when deploying e.g. with the ${styles.accent(
         "garden deploy"
       )} command. You can also delete the resource from your cluster manually and try again.`,
     })
   } else {
     if (production && force) {
-      log.warn(
-        chalk.yellow(`${msgPrefix} Since we're deploying with force = true, we'll now delete it before redeploying.`)
-      )
+      log.warn(`${msgPrefix} Since we're deploying with force = true, we'll now delete it before redeploying.`)
     } else if (!production) {
       log.warn(
-        chalk.yellow(
-          `${msgPrefix} Since this environment does not have production = true, we'll now delete it before redeploying.`
-        )
+        `${msgPrefix} Since this environment does not have production = true, we'll now delete it before redeploying.`
       )
     }
     await deleteResourceKeys({
diff --git a/core/src/plugins/kubernetes/container/ingress.ts b/core/src/plugins/kubernetes/container/ingress.ts
index 78c7f80dae..3858fbed2c 100644
--- a/core/src/plugins/kubernetes/container/ingress.ts
+++ b/core/src/plugins/kubernetes/container/ingress.ts
@@ -19,7 +19,6 @@ import { getHostnamesFromPem } from "../../../util/tls.js"
 import type { KubernetesResource } from "../types.js"
 import type { V1Ingress, V1Secret } from "@kubernetes/client-node"
 import type { Log } from "../../../logger/log-entry.js"
-import chalk from "chalk"
 import type { Resolved } from "../../../actions/types.js"
 import { isProviderEphemeralKubernetes } from "../ephemeral/ephemeral.js"
 
@@ -68,7 +67,7 @@ export async function createIngressResources(
   const apiVersion = await getIngressApiVersion(log, api, supportedIngressApiVersions)
 
   if (!apiVersion) {
-    log.warn(chalk.yellow(`Could not find a supported Ingress API version in the target cluster`))
+    log.warn(`Could not find a supported Ingress API version in the target cluster`)
     return []
   }
 
diff --git a/core/src/plugins/kubernetes/ephemeral/config.ts b/core/src/plugins/kubernetes/ephemeral/config.ts
index a0f0891914..d30bc66f5c 100644
--- a/core/src/plugins/kubernetes/ephemeral/config.ts
+++ b/core/src/plugins/kubernetes/ephemeral/config.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import fsExtra from "fs-extra"
 const { mkdirp, writeFile } = fsExtra
 import { load } from "js-yaml"
@@ -23,6 +22,7 @@ import { namespaceSchema } from "../config.js"
 import { EPHEMERAL_KUBERNETES_PROVIDER_NAME } from "./ephemeral.js"
 import { DEFAULT_GARDEN_CLOUD_DOMAIN } from "../../../constants.js"
 import { defaultSystemNamespace } from "../constants.js"
+import { styles } from "../../../logger/styles.js"
 
 export type EphemeralKubernetesClusterType = "ephemeral"
 
@@ -71,7 +71,7 @@ export async function configureProvider(params: ConfigureProviderParams<Kubernet
   const deadlineDateTime = moment(createEphemeralClusterResponse.instanceMetadata.deadline)
   const diffInNowAndDeadline = moment.duration(deadlineDateTime.diff(moment())).asMinutes().toFixed(1)
   log.info(
-    chalk.white(
+    styles.accent(
       `Ephemeral cluster will be destroyed in ${diffInNowAndDeadline} minutes, at ${deadlineDateTime.format(
         "YYYY-MM-DD HH:mm:ss"
       )}`
@@ -83,7 +83,7 @@ export async function configureProvider(params: ConfigureProviderParams<Kubernet
   const kubeconfigFileName = `${clusterId}-kubeconfig.yaml`
   const kubeConfigPath = join(ctx.gardenDirPath, "ephemeral-kubernetes", kubeconfigFileName)
   await writeFile(kubeConfigPath, kubeConfig)
-  log.info(`Kubeconfig for ephemeral cluster saved at path: ${chalk.underline(kubeConfigPath)}`)
+  log.info(`Kubeconfig for ephemeral cluster saved at path: ${styles.underline(kubeConfigPath)}`)
 
   const parsedKubeConfig: any = load(kubeConfig)
   baseConfig.context = parsedKubeConfig["current-context"]
diff --git a/core/src/plugins/kubernetes/helm/exec.ts b/core/src/plugins/kubernetes/helm/exec.ts
index 9cf077f9dd..6f0b12458f 100644
--- a/core/src/plugins/kubernetes/helm/exec.ts
+++ b/core/src/plugins/kubernetes/helm/exec.ts
@@ -15,7 +15,7 @@ import { getHelmDeployStatus } from "./status.js"
 import { getChartResources } from "./common.js"
 import type { DeployActionHandler } from "../../../plugin/action-types.js"
 import type { HelmDeployAction } from "./config.js"
-import chalk from "chalk"
+import { styles } from "../../../logger/styles.js"
 
 export const execInHelmDeploy: DeployActionHandler<"exec", HelmDeployAction> = async (params) => {
   const { ctx, log, action, command, interactive } = params
@@ -27,7 +27,7 @@ export const execInHelmDeploy: DeployActionHandler<"exec", HelmDeployAction> = a
 
   if (!defaultTarget) {
     throw new ConfigurationError({
-      message: `${action.longDescription()} does not specify a defaultTarget. Please configure this in order to be able to use this command with. This is currently necessary for the ${chalk.white(
+      message: `${action.longDescription()} does not specify a defaultTarget. Please configure this in order to be able to use this command with. This is currently necessary for the ${styles.accent(
         "exec"
       )} command to work with helm Deploy actions.`,
     })
diff --git a/core/src/plugins/kubernetes/kubernetes-type/exec.ts b/core/src/plugins/kubernetes/kubernetes-type/exec.ts
index d7f97451cc..4a2648481f 100644
--- a/core/src/plugins/kubernetes/kubernetes-type/exec.ts
+++ b/core/src/plugins/kubernetes/kubernetes-type/exec.ts
@@ -14,7 +14,7 @@ import { execInWorkload, getTargetResource } from "../util.js"
 import type { DeployActionHandler } from "../../../plugin/action-types.js"
 import type { KubernetesDeployAction } from "./config.js"
 import { getKubernetesDeployStatus } from "./handlers.js"
-import chalk from "chalk"
+import { styles } from "../../../logger/styles.js"
 
 export const execInKubernetesDeploy: DeployActionHandler<"exec", KubernetesDeployAction> = async (params) => {
   const { ctx, log, action, command, interactive, target: containerName } = params
@@ -26,7 +26,7 @@ export const execInKubernetesDeploy: DeployActionHandler<"exec", KubernetesDeplo
 
   if (!defaultTarget) {
     throw new ConfigurationError({
-      message: `${action.longDescription()} does not specify a defaultTarget. Please configure this in order to be able to use this command with. This is currently necessary for the ${chalk.white(
+      message: `${action.longDescription()} does not specify a defaultTarget. Please configure this in order to be able to use this command with. This is currently necessary for the ${styles.accent(
         "exec"
       )} command to work with kubernetes Deploy actions.`,
     })
diff --git a/core/src/plugins/kubernetes/kubernetes-type/kubernetes-exec.ts b/core/src/plugins/kubernetes/kubernetes-type/kubernetes-exec.ts
index affa036607..00b5ec0a56 100644
--- a/core/src/plugins/kubernetes/kubernetes-type/kubernetes-exec.ts
+++ b/core/src/plugins/kubernetes/kubernetes-type/kubernetes-exec.ts
@@ -7,7 +7,6 @@
  */
 
 import type { ObjectSchema } from "@hapi/joi"
-import chalk from "chalk"
 import { runResultToActionState } from "../../../actions/base.js"
 import type { RunAction, RunActionConfig } from "../../../actions/run.js"
 import type { TestAction, TestActionConfig } from "../../../actions/test.js"
@@ -149,12 +148,10 @@ async function readAndExec({
     }
     if (err.responseStatusCode === 404) {
       throw new ConfigurationError({
-        message: chalk.red(
-          dedent`
+        message: dedent`
             ${action.longDescription()} specifies target resource ${targetKind}/${targetName}, which could not be found in namespace ${namespace}.
 
-            Hint: This action may be missing a dependency on a Deploy in this project that deploys the target resource. If so, adding that dependency will ensure that the Deploy is run before this action.`
-        ),
+            Hint: This action may be missing a dependency on a Deploy in this project that deploys the target resource. If so, adding that dependency will ensure that the Deploy is run before this action.`,
       })
     } else {
       throw err
diff --git a/core/src/plugins/kubernetes/local-mode.ts b/core/src/plugins/kubernetes/local-mode.ts
index ef19178749..d499a3e595 100644
--- a/core/src/plugins/kubernetes/local-mode.ts
+++ b/core/src/plugins/kubernetes/local-mode.ts
@@ -25,7 +25,6 @@ import type { V1Container, V1ContainerPort } from "@kubernetes/client-node"
 import type { KubernetesPluginContext, KubernetesTargetResourceSpec } from "./config.js"
 import { targetResourceSpecSchema } from "./config.js"
 import type { ActionLog, Log } from "../../logger/log-entry.js"
-import chalk from "chalk"
 import { rmSync } from "fs"
 import { execSync } from "child_process"
 import { isAbsolute, join } from "path"
@@ -42,6 +41,7 @@ import touch from "touch"
 import type { Resolved } from "../../actions/types.js"
 import { DOCS_BASE_URL } from "../../constants.js"
 import AsyncLock from "async-lock"
+import { styles } from "../../logger/styles.js"
 
 export const localModeGuideLink = `${DOCS_BASE_URL}/guides/running-service-in-local-mode`
 
@@ -460,7 +460,9 @@ export async function configureLocalMode(configParams: ConfigureLocalModeParams)
   })
 
   // Logging this on the debug level because it can be displayed multiple times due to getServiceStatus checks
-  log.debug(`Configuring in local mode, proxy container ${chalk.underline(k8sReverseProxyImageName)} will be deployed.`)
+  log.debug(
+    `Configuring in local mode, proxy container ${styles.underline(k8sReverseProxyImageName)} will be deployed.`
+  )
 
   set(resolvedTarget, ["metadata", "annotations", gardenAnnotationKey("mode")], "local")
 
@@ -576,9 +578,9 @@ function getLocalAppProcess(configParams: StartLocalModeParams): RecoverableProc
       hasErrors: (_chunk: any) => true,
       onError: (msg: ProcessMessage) => {
         if (msg.code || msg.signal) {
-          processLog.error(chalk.gray(composeErrorMessage("Local app stopped", msg)))
+          processLog.error(styles.primary(composeErrorMessage("Local app stopped", msg)))
         } else {
-          processLog.error(chalk.gray(composeErrorMessage(`Cannot start the local app`, msg)))
+          processLog.error(styles.primary(composeErrorMessage(`Cannot start the local app`, msg)))
         }
         localAppFailureCounter.addFailure(() => {
           processLog.error(dedent`${
@@ -596,7 +598,7 @@ function getLocalAppProcess(configParams: StartLocalModeParams): RecoverableProc
       onError: (_msg: ProcessMessage) => {},
       onMessage: (msg: ProcessMessage) => {
         processLog.verbose({
-          msg: chalk.gray(composeMessage(msg, stripEol(msg.message))),
+          msg: styles.primary(composeMessage(msg, stripEol(msg.message))),
         })
       },
     },
@@ -654,7 +656,7 @@ async function getKubectlPortForwardProcess(
       catchCriticalErrors: (_chunk: any) => false,
       hasErrors: (_chunk: any) => true,
       onError: (msg: ProcessMessage) => {
-        processLog.error(chalk.gray(composeErrorMessage(`${msg.processDescription} failed`, msg)))
+        processLog.error(styles.primary(composeErrorMessage(`${msg.processDescription} failed`, msg)))
         kubectlPortForwardFailureCounter.addFailure(() => {
           processLog.error(dedent`${
             msg.processDescription
@@ -677,7 +679,7 @@ async function getKubectlPortForwardProcess(
         }
 
         if (msg.message.includes("Handling connection for")) {
-          processLog.info(chalk.white(consoleMessage))
+          processLog.info(styles.accent(consoleMessage))
           lastSeenSuccessMessage = consoleMessage
         }
       },
@@ -726,7 +728,7 @@ async function getReversePortForwardProcesses(
 
   return reversePortForwardingCmds.map((cmd) => {
     // Include origin with logs for clarity
-    const log = configParams.log.createLog({ origin: chalk.gray(cmd.command) })
+    const log = configParams.log.createLog({ origin: styles.primary(cmd.command) })
 
     return new RecoverableProcess({
       events: ctx.events,
@@ -742,9 +744,7 @@ async function getReversePortForwardProcesses(
           const lowercaseOutput = output.toLowerCase()
           if (lowercaseOutput.includes('unsupported option "accept-new"')) {
             log.error({
-              msg: chalk.red(
-                "It looks like you're using too old SSH version which doesn't support option -oStrictHostKeyChecking=accept-new. Consider upgrading to OpenSSH 7.6 or higher. Local mode will not work."
-              ),
+              msg: "It looks like you're using too old SSH version which doesn't support option -oStrictHostKeyChecking=accept-new. Consider upgrading to OpenSSH 7.6 or higher. Local mode will not work.",
             })
             return true
           }
@@ -757,9 +757,7 @@ async function getReversePortForwardProcesses(
             lowercaseOutput.includes(indicator)
           })
           if (hasCriticalErrors) {
-            log.error({
-              msg: chalk.red(output),
-            })
+            log.error(output)
           }
           return hasCriticalErrors
         },
@@ -773,7 +771,7 @@ async function getReversePortForwardProcesses(
         },
         onError: (msg: ProcessMessage) => {
           log.error({
-            msg: chalk.gray(composeErrorMessage(`${msg.processDescription} port-forward failed`, msg)),
+            msg: styles.primary(composeErrorMessage(`${msg.processDescription} port-forward failed`, msg)),
           })
           reversePortForwardFailureCounter.addFailure(() => {
             log.error(`${
@@ -784,7 +782,7 @@ async function getReversePortForwardProcesses(
         },
         onMessage: (msg: ProcessMessage) => {
           log.success({
-            msg: chalk.white(composeMessage(msg, `${msg.processDescription} is up and running`)),
+            msg: styles.accent(composeMessage(msg, `${msg.processDescription} is up and running`)),
           })
         },
       },
@@ -794,7 +792,7 @@ async function getReversePortForwardProcesses(
         onError: (_msg: ProcessMessage) => {},
         onMessage: (msg: ProcessMessage) => {
           log.success({
-            msg: chalk.white(composeMessage(msg, `${msg.processDescription} is up and running`)),
+            msg: styles.accent(composeMessage(msg, `${msg.processDescription} is up and running`)),
           })
         },
       },
@@ -834,14 +832,14 @@ export async function startServiceInLocalMode(configParams: StartLocalModeParams
   }
 
   log.info({
-    msg: chalk.gray("Starting in local mode..."),
+    msg: styles.primary("Starting in local mode..."),
   })
 
   registerCleanupFunction(`redeploy-alert-for-local-mode-${action.key()}`, () => {
     log.warn(
       `Local mode has been stopped for the action "${action.key()}". ` +
         "Please, re-deploy the original service to restore the original k8s cluster state: " +
-        `${chalk.white(`\`garden deploy ${action.name}\``)}`
+        `${styles.accent(`\`garden deploy ${action.name}\``)}`
     )
   })
 
@@ -853,7 +851,7 @@ export async function startServiceInLocalMode(configParams: StartLocalModeParams
   const localApp = getLocalAppProcess(configParams)
   if (!!localApp) {
     log.info({
-      msg: chalk.white("Starting local app, this can take a while"),
+      msg: styles.accent("Starting local app, this can take a while"),
     })
     const localAppStatus = localModeProcessRegistry.submit(localApp)
     if (!localAppStatus) {
@@ -873,11 +871,11 @@ export async function startServiceInLocalMode(configParams: StartLocalModeParams
 
   const compositeSshTunnel = composeSshTunnelProcessTree(kubectlPortForward, reversePortForwards, log)
   log.info({
-    msg: chalk.white("Starting local mode ssh tunnels, some failures and retries are possible"),
+    msg: styles.accent("Starting local mode ssh tunnels, some failures and retries are possible"),
   })
   const sshTunnelCmdRenderer = (command: OsCommand) => `${command.command} ${command.args?.join(" ")}`
   log.verbose({
-    msg: chalk.gray(
+    msg: styles.primary(
       `Starting the process tree for the local mode ssh tunnels:\n` +
         `${compositeSshTunnel.renderProcessTree(sshTunnelCmdRenderer)}`
     ),
diff --git a/core/src/plugins/kubernetes/local/microk8s.ts b/core/src/plugins/kubernetes/local/microk8s.ts
index 85bbde37f1..b5280a2052 100644
--- a/core/src/plugins/kubernetes/local/microk8s.ts
+++ b/core/src/plugins/kubernetes/local/microk8s.ts
@@ -12,7 +12,6 @@ import type { Log } from "../../../logger/log-entry.js"
 import { exec } from "../../../util/util.js"
 import { containerHelpers } from "../../container/helpers.js"
 import type { ContainerBuildAction } from "../../container/moduleConfig.js"
-import chalk from "chalk"
 import { deline, naturalList } from "../../../util/string.js"
 import type { ExecaReturnValue } from "execa"
 import type { PluginContext } from "../../../plugin-context.js"
@@ -32,12 +31,10 @@ export async function configureMicrok8sAddons(log: Log, addons: string[]) {
       throw err
     }
     if (err.details.output.includes("permission denied") || err.details.output.includes("Insufficient permissions")) {
-      microK8sLog.warn(
-        chalk.yellow(
-          deline`Unable to get microk8s status and manage addons. You may need to add the current user to the microk8s
-          group. Alternatively, you can manually ensure that the ${naturalList(addons)} are enabled.`
-        )
-      )
+      microK8sLog.warn(deline`
+        Unable to get microk8s status and manage addons. You may need to add the current user to the microk8s
+        group. Alternatively, you can manually ensure that the ${naturalList(addons)} are enabled.
+      `)
       return
     } else {
       statusCommandResult = err
diff --git a/core/src/plugins/kubernetes/namespace.ts b/core/src/plugins/kubernetes/namespace.ts
index 865a56a2d0..b75279e72d 100644
--- a/core/src/plugins/kubernetes/namespace.ts
+++ b/core/src/plugins/kubernetes/namespace.ts
@@ -19,7 +19,6 @@ import { gardenAnnotationKey } from "../../util/string.js"
 import dedent from "dedent"
 import type { V1Namespace } from "@kubernetes/client-node"
 import { isSubset } from "../../util/is-subset.js"
-import chalk from "chalk"
 import type { NamespaceStatus } from "../../types/namespace.js"
 import type { KubernetesServerResource, SupportedRuntimeAction } from "./types.js"
 import type { Resolved } from "../../actions/types.js"
@@ -141,7 +140,7 @@ export async function ensureNamespace(
           })
           result.patched = true
         } catch {
-          log.warn(chalk.yellow(`Unable to apply the configured annotations and labels on namespace ${namespace.name}`))
+          log.warn(`Unable to apply the configured annotations and labels on namespace ${namespace.name}`)
         }
       }
 
diff --git a/core/src/plugins/kubernetes/retry.ts b/core/src/plugins/kubernetes/retry.ts
index 103650b07f..48280b49af 100644
--- a/core/src/plugins/kubernetes/retry.ts
+++ b/core/src/plugins/kubernetes/retry.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import httpStatusCodes from "http-status-codes"
 import { ApiException as KubernetesApiException } from "@kubernetes/client-node"
 import { sleep } from "../../util/util.js"
@@ -62,7 +61,7 @@ export async function requestWithRetry<R>(
           return await retry(usedRetries + 1)
         } else {
           if (usedRetries === maxRetries) {
-            retryLog.info(chalk.red(`Kubernetes API: Maximum retry count exceeded`))
+            retryLog.error(`Kubernetes API: Maximum retry count exceeded`)
           }
           throw err
         }
diff --git a/core/src/plugins/kubernetes/run-results.ts b/core/src/plugins/kubernetes/run-results.ts
index c2d5fb66bf..5a4ae08d6e 100644
--- a/core/src/plugins/kubernetes/run-results.ts
+++ b/core/src/plugins/kubernetes/run-results.ts
@@ -17,7 +17,6 @@ import { gardenAnnotationKey } from "../../util/string.js"
 import hasha from "hasha"
 import { upsertConfigMap } from "./util.js"
 import { trimRunOutput } from "./helm/common.js"
-import chalk from "chalk"
 import { runResultToActionState } from "../../actions/base.js"
 import type { Action } from "../../actions/types.js"
 import type { RunResult } from "../../plugin/base.js"
@@ -105,7 +104,7 @@ export async function storeRunResult({ ctx, log, action, result }: StoreTaskResu
       data,
     })
   } catch (err) {
-    log.warn(chalk.yellow(`Unable to store Run result: ${err}`))
+    log.warn(`Unable to store Run result: ${err}`)
   }
 
   return data
diff --git a/core/src/plugins/kubernetes/status/pod.ts b/core/src/plugins/kubernetes/status/pod.ts
index 34f6e7efda..83d6bb5e1d 100644
--- a/core/src/plugins/kubernetes/status/pod.ts
+++ b/core/src/plugins/kubernetes/status/pod.ts
@@ -11,10 +11,10 @@ import { KubernetesError } from "../api.js"
 import type { KubernetesServerResource, KubernetesPod } from "../types.js"
 import type { V1Pod, V1Status } from "@kubernetes/client-node"
 import type { ResourceStatus } from "./status.js"
-import chalk from "chalk"
 import type { DeployState } from "../../../types/service.js"
 import { combineStates } from "../../../types/service.js"
 import stringify from "json-stringify-safe"
+import { styles } from "../../../logger/styles.js"
 
 export const POD_LOG_LINES = 30
 
@@ -181,9 +181,9 @@ export async function getFormattedPodLogs(api: KubeApi, namespace: string, pods:
   return allLogs
     .map(({ podName, containers }) => {
       return (
-        chalk.blueBright(`\n****** ${podName} ******\n`) +
+        styles.highlight(`\n****** ${podName} ******\n`) +
         containers.map(({ containerName, log }) => {
-          return chalk.gray(`------ ${containerName} ------`) + (log || "<no logs>")
+          return styles.primary(`------ ${containerName} ------`) + (log || "<no logs>")
         })
       )
     })
diff --git a/core/src/plugins/kubernetes/status/workload.ts b/core/src/plugins/kubernetes/status/workload.ts
index 1bb6d881ee..b8d1e1687a 100644
--- a/core/src/plugins/kubernetes/status/workload.ts
+++ b/core/src/plugins/kubernetes/status/workload.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { flatten, sortBy } from "lodash-es"
 import type { KubernetesPod, KubernetesServerResource } from "../types.js"
 import type {
@@ -25,6 +24,7 @@ import { getCurrentWorkloadPods, renderPodEvents } from "../util.js"
 import { getFormattedPodLogs, POD_LOG_LINES } from "./pod.js"
 import type { ResourceStatus, StatusHandlerParams } from "./status.js"
 import { getResourceEvents } from "./events.js"
+import { styles } from "../../../logger/styles.js"
 
 const containerStatusFailures = ["CrashLoopBackOff", "CreateContainerConfigError", "ImagePullBackOff"]
 
@@ -79,9 +79,9 @@ export async function checkWorkloadStatus({ api, namespace, resource }: StatusHa
     const podLogs = (await getFormattedPodLogs(api, namespace, pods)) || undefined
 
     if (podLogs) {
-      logs += chalk.white("\n\n━━━ Pod logs ━━━\n")
+      logs += styles.accent("\n\n━━━ Pod logs ━━━\n")
       logs +=
-        chalk.gray(dedent`
+        styles.primary(dedent`
       <Showing last ${POD_LOG_LINES} lines per pod in this ${
         workload.kind
       }. Run the following command for complete logs>
diff --git a/core/src/plugins/kubernetes/sync.ts b/core/src/plugins/kubernetes/sync.ts
index 4e03dfed59..ca332eecd4 100644
--- a/core/src/plugins/kubernetes/sync.ts
+++ b/core/src/plugins/kubernetes/sync.ts
@@ -45,7 +45,6 @@ import type {
   SyncableRuntimeAction,
 } from "./types.js"
 import type { ActionLog, Log } from "../../logger/log-entry.js"
-import chalk from "chalk"
 import { joi, joiIdentifier } from "../../config/common.js"
 import type {
   KubernetesPluginContext,
@@ -71,6 +70,7 @@ import { prepareConnectionOpts } from "./kubectl.js"
 import type { GetSyncStatusResult, SyncState, SyncStatus } from "../../plugin/handlers/Deploy/get-sync-status.js"
 import { ConfigurationError } from "../../exceptions.js"
 import { DOCS_BASE_URL } from "../../constants.js"
+import { styles } from "../../logger/styles.js"
 
 export const builtInExcludes = ["/**/*.git", "**/*.garden"]
 
@@ -568,12 +568,12 @@ export async function startSyncs(params: StartSyncsParams) {
 
       // Validate the target
       if (!isConfiguredForSyncMode(target)) {
-        log.warn(chalk.yellow(`Resource ${resourceName} is not deployed in sync mode, cannot start sync.`))
+        log.warn(`Resource ${resourceName} is not deployed in sync mode, cannot start sync.`)
         return
       }
 
       if (!containerName) {
-        log.warn(chalk.yellow(`Resource ${resourceName} doesn't have any containers, cannot start sync.`))
+        log.warn(`Resource ${resourceName} doesn't have any containers, cannot start sync.`)
         return
       }
 
@@ -843,8 +843,8 @@ async function prepareSync(params: PrepareSyncParams) {
 
   const key = getSyncKey(params, target)
 
-  const localPathDescription = chalk.white(spec.sourcePath)
-  const remoteDestinationDescription = `${chalk.white(spec.containerPath)} in ${chalk.white(resourceName)}`
+  const localPathDescription = styles.accent(spec.sourcePath)
+  const remoteDestinationDescription = `${styles.accent(spec.containerPath)} in ${styles.accent(resourceName)}`
 
   const {
     source: orientedSource,
diff --git a/core/src/plugins/kubernetes/test-results.ts b/core/src/plugins/kubernetes/test-results.ts
index b99bfa26dc..6790285105 100644
--- a/core/src/plugins/kubernetes/test-results.ts
+++ b/core/src/plugins/kubernetes/test-results.ts
@@ -18,7 +18,6 @@ import { gardenAnnotationKey } from "../../util/string.js"
 import { upsertConfigMap } from "./util.js"
 import { trimRunOutput } from "./helm/common.js"
 import { getSystemNamespace } from "./namespace.js"
-import chalk from "chalk"
 import type { TestActionHandler } from "../../plugin/action-types.js"
 import { runResultToActionState } from "../../actions/base.js"
 import type { HelmPodTestAction } from "./helm/config.js"
@@ -96,7 +95,7 @@ export async function storeTestResult({ ctx, log, action, result }: StoreTestRes
       data,
     })
   } catch (err) {
-    log.warn(chalk.yellow(`Unable to store test result: ${err}`))
+    log.warn(`Unable to store test result: ${err}`)
   }
 
   return data
diff --git a/core/src/plugins/kubernetes/util.ts b/core/src/plugins/kubernetes/util.ts
index a9b3f62bf9..2d20a0d0c6 100644
--- a/core/src/plugins/kubernetes/util.ts
+++ b/core/src/plugins/kubernetes/util.ts
@@ -9,7 +9,6 @@
 import { get, flatten, sortBy, omit, sample, isEmpty, find, cloneDeep, uniqBy } from "lodash-es"
 import type { V1Pod, V1EnvVar, V1Container, V1PodSpec, CoreV1Event } from "@kubernetes/client-node"
 import { apply as jsonMerge } from "json-merge-patch"
-import chalk from "chalk"
 import hasha from "hasha"
 
 import type {
@@ -51,6 +50,7 @@ import { getActionNamespace } from "./namespace.js"
 import type { Resolved } from "../../actions/types.js"
 import { serializeValues } from "../../util/serialization.js"
 import { PassThrough } from "stream"
+import { styles } from "../../logger/styles.js"
 
 const STATIC_LABEL_REGEX = /[0-9]/g
 export const workloadTypes = ["Deployment", "DaemonSet", "ReplicaSet", "StatefulSet"]
@@ -586,10 +586,9 @@ export async function getTargetResource({
     if (!pod) {
       const selectorStr = getSelectorString(query.podSelector)
       throw new ConfigurationError({
-        message: chalk.red(
+        message:
           `Could not find any Pod matching provided podSelector (${selectorStr}) for target in ` +
-            `${action.longDescription()}`
-        ),
+          `${action.longDescription()}`,
       })
     }
     return pod
@@ -632,7 +631,7 @@ export async function getTargetResource({
       if (!target) {
         throw new ConfigurationError({
           message: dedent`
-            ${action.longDescription()} does not contain specified ${targetKind} ${chalk.white(targetName)}
+            ${action.longDescription()} does not contain specified ${targetKind} ${styles.accent(targetName)}
 
             The chart does declare the following resources: ${naturalList(chartResourceNames)}
             `,
@@ -651,14 +650,12 @@ export async function getTargetResource({
 
       if (applicableChartResources.length > 1) {
         throw new ConfigurationError({
-          message: chalk.red(
-            deline`${action.longDescription()} contains multiple ${targetKind}s.
+          message: deline`${action.longDescription()} contains multiple ${targetKind}s.
             You must specify a resource name in the appropriate config in order to identify the correct ${targetKind}
             to use.
 
             The chart declares the following resources: ${naturalList(chartResourceNames)}
-            `
-          ),
+            `,
         })
       }
 
@@ -678,9 +675,7 @@ export async function getTargetResource({
     }
     if (err.responseStatusCode === 404) {
       throw new ConfigurationError({
-        message: chalk.red(
-          deline`${action.longDescription()} specifies target resource ${targetKind}/${targetName}, which could not be found in namespace ${namespace}.`
-        ),
+        message: deline`${action.longDescription()} specifies target resource ${targetKind}/${targetName}, which could not be found in namespace ${namespace}.`,
       })
     } else {
       throw err
@@ -794,18 +789,18 @@ export function getK8sProvider(providers: ProviderMap): KubernetesProvider {
 export function renderPodEvents(events: CoreV1Event[]): string {
   let text = ""
 
-  text += `${chalk.white("━━━ Events ━━━")}\n`
+  text += `${styles.accent("━━━ Events ━━━")}\n`
   for (const event of events) {
     const obj = event.involvedObject
-    const name = chalk.blueBright(`${obj.kind} ${obj.name}:`)
+    const name = styles.highlight(`${obj.kind} ${obj.name}:`)
     const msg = `${event.reason} - ${event.message}`
     const colored =
-      event.type === "Error" ? chalk.red(msg) : event.type === "Warning" ? chalk.yellow(msg) : chalk.white(msg)
+      event.type === "Error" ? styles.error(msg) : event.type === "Warning" ? styles.warning(msg) : styles.accent(msg)
     text += `${name} ${colored}\n`
   }
 
   if (events.length === 0) {
-    text += `${chalk.red("No matching events found")}\n`
+    text += `${styles.error("No matching events found")}\n`
   }
 
   return text
diff --git a/core/src/proxy.ts b/core/src/proxy.ts
index 8f3169eb8a..5312c61234 100644
--- a/core/src/proxy.ts
+++ b/core/src/proxy.ts
@@ -7,7 +7,6 @@
  */
 
 import { isEqual, invert } from "lodash-es"
-import chalk from "chalk"
 import type { Server } from "net"
 import { createServer, Socket } from "net"
 import AsyncLock from "async-lock"
@@ -126,10 +125,10 @@ async function createProxy({ garden, graph, log, action, spec, events }: StartPo
         const msg = err.message.trim()
 
         if (msg !== lastPrintedError) {
-          log.warn(chalk.gray(`β†’ Could not start port forward to ${key} (will retry): ${msg}`))
+          log.warn(`β†’ Could not start port forward to ${key} (will retry): ${msg}`)
           lastPrintedError = msg
         } else {
-          log.silly(chalk.gray(`β†’ Could not start port forward to ${key} (will retry): ${msg}`))
+          log.silly(`β†’ Could not start port forward to ${key} (will retry): ${msg}`)
         }
       }
 
@@ -278,9 +277,7 @@ async function createProxy({ garden, graph, log, action, spec, events }: StartPo
 
     if (started) {
       if (spec.preferredLocalPort && (localIp !== defaultLocalAddress || localPort !== spec.preferredLocalPort)) {
-        log.warn(
-          chalk.yellow(`β†’ Unable to bind port forward ${key} to preferred local port ${spec.preferredLocalPort}`)
-        )
+        log.warn(`β†’ Unable to bind port forward ${key} to preferred local port ${spec.preferredLocalPort}`)
       }
 
       return { key, server, action, spec, localPort, localUrl }
diff --git a/core/src/resolve-module.ts b/core/src/resolve-module.ts
index 891e972f19..964e8e4a75 100644
--- a/core/src/resolve-module.ts
+++ b/core/src/resolve-module.ts
@@ -36,7 +36,6 @@ import { getLinkedSources } from "./util/ext-source-util.js"
 import type { ActionReference, DeepPrimitiveMap } from "./config/common.js"
 import { allowUnknown } from "./config/common.js"
 import type { ProviderMap } from "./config/provider.js"
-import chalk from "chalk"
 import { DependencyGraph } from "./graph/common.js"
 import fsExtra from "fs-extra"
 const { mkdirp, readFile } = fsExtra
@@ -58,6 +57,7 @@ import type { ModuleGraph } from "./graph/modules.js"
 import type { GraphResults } from "./graph/results.js"
 import type { ExecBuildConfig } from "./plugins/exec/build.js"
 import { pMemoizeDecorator } from "./lib/p-memoize.js"
+import { styles } from "./logger/styles.js"
 
 // This limit is fairly arbitrary, but we need to have some cap on concurrent processing.
 export const moduleResolutionConcurrencyLimit = 50
@@ -206,12 +206,12 @@ export class ModuleResolver {
     const processLeaves = async () => {
       if (Object.keys(errors).length > 0) {
         const errorStr = Object.entries(errors)
-          .map(([name, err]) => `${chalk.white.bold(name)}: ${err.message}`)
+          .map(([name, err]) => `${styles.accent.bold(name)}: ${err.message}`)
           .join("\n")
         const msg = `Failed resolving one or more modules:\n\n${errorStr}`
 
         const combined = new ConfigurationError({
-          message: chalk.red(msg),
+          message: msg,
           wrappedErrors: Object.values(errors),
         })
         throw combined
@@ -919,9 +919,8 @@ function inheritModuleToAction(module: GardenModule, action: ActionConfig) {
 
 function missingBuildDependency(moduleName: string, dependencyName: string) {
   return new ConfigurationError({
-    message: chalk.red(
-      `Could not find build dependency ${chalk.white(dependencyName)}, ` +
-        `configured in module ${chalk.white(moduleName)}`
-    ),
+    message:
+      `Could not find build dependency ${styles.accent(dependencyName)}, ` +
+      `configured in module ${styles.accent(moduleName)}`,
   })
 }
diff --git a/core/src/router/build.ts b/core/src/router/build.ts
index 52688d3be4..31c1b6d7be 100644
--- a/core/src/router/build.ts
+++ b/core/src/router/build.ts
@@ -6,12 +6,11 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
-
 import { PluginEventBroker } from "../plugin-context.js"
 import type { BaseRouterParams } from "./base.js"
 import { createActionRouter } from "./base.js"
 import type { PublishActionResult } from "../plugin/handlers/Build/publish.js"
+import { styles } from "../logger/styles.js"
 
 const API_ACTION_TYPE = "build"
 
@@ -76,7 +75,7 @@ const dummyPublishHandler = async ({ action }): Promise<PublishActionResult> =>
   return {
     state: "unknown",
     detail: {
-      message: chalk.yellow(`No publish handler available for type ${action.type}`),
+      message: styles.warning(`No publish handler available for type ${action.type}`),
       published: false,
     },
     outputs: {},
diff --git a/core/src/router/deploy.ts b/core/src/router/deploy.ts
index 5c76ad96a1..6abd941863 100644
--- a/core/src/router/deploy.ts
+++ b/core/src/router/deploy.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type { ActionState } from "../actions/types.js"
 import { PluginEventBroker } from "../plugin-context.js"
 import type { DeployState } from "../types/service.js"
@@ -92,7 +91,7 @@ export const deployRouter = (baseParams: BaseRouterParams) =>
         params,
         handlerType: "getLogs",
         defaultHandler: async () => {
-          log.warn(chalk.yellow(`No handler for log retrieval available for action type ${action.type}`))
+          log.warn(`No handler for log retrieval available for action type ${action.type}`)
           return {}
         },
       })
@@ -137,7 +136,7 @@ export const deployRouter = (baseParams: BaseRouterParams) =>
         params,
         handlerType: "startSync",
         defaultHandler: async () => {
-          log.debug(chalk.yellow(`No startSync handler available for action type ${action.type}`))
+          log.debug(`No startSync handler available for action type ${action.type}`)
           return {}
         },
       })
@@ -150,7 +149,7 @@ export const deployRouter = (baseParams: BaseRouterParams) =>
         params,
         handlerType: "stopSync",
         defaultHandler: async () => {
-          log.debug(chalk.yellow(`No stopSync handler available for action type ${action.type}`))
+          log.debug(`No stopSync handler available for action type ${action.type}`)
           return {}
         },
       })
diff --git a/core/src/router/provider.ts b/core/src/router/provider.ts
index 60c96efbeb..c68e68e711 100644
--- a/core/src/router/provider.ts
+++ b/core/src/router/provider.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { fromPairs, mapValues, omit } from "lodash-es"
 import pProps from "p-props"
 
@@ -46,6 +45,7 @@ import { Profile } from "../util/profiling.js"
 import type { GetDashboardPageParams, GetDashboardPageResult } from "../plugin/handlers/Provider/getDashboardPage.js"
 import type { CommonParams, BaseRouterParams } from "./base.js"
 import { BaseRouter } from "./base.js"
+import { styles } from "../logger/styles.js"
 
 /**
  * The ProviderRouter takes care of choosing which plugin should be responsible for handling a provider action,
@@ -176,7 +176,7 @@ export class ProviderRouter extends BaseRouter {
    * Runs cleanupEnvironment for all configured providers
    */
   async cleanupAll(log: Log) {
-    log.info(chalk.white("Cleaning up environments..."))
+    log.info(styles.accent("Cleaning up environments..."))
     const environmentStatuses: EnvironmentStatusMap = {}
 
     const providers = await this.garden.resolveProviders(log)
diff --git a/core/src/router/router.ts b/core/src/router/router.ts
index c67aa3921d..08a1a4cdf1 100644
--- a/core/src/router/router.ts
+++ b/core/src/router/router.ts
@@ -6,8 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
-
 import type { Garden } from "../garden.js"
 import type { Log } from "../logger/log-entry.js"
 import type { GardenPluginSpec, ModuleTypeDefinition, PluginActionContextParams } from "../plugin/plugin.js"
@@ -27,6 +25,7 @@ import { testRouter } from "./test.js"
 import type { DeployStatus, DeployStatusMap } from "../plugin/handlers/Deploy/get-status.js"
 import type { GetActionOutputsParams, GetActionOutputsResult } from "../plugin/handlers/base/get-outputs.js"
 import type { ActionKind, BaseActionConfig, ResolvedAction } from "../actions/types.js"
+import { styles } from "../logger/styles.js"
 
 export interface DeployManyParams {
   graph: ConfigGraph
@@ -162,7 +161,7 @@ export class ActionRouter extends BaseRouter {
     dependantsFirst?: boolean
     names?: string[]
   }): Promise<DeployStatusMap> {
-    const servicesLog = log.createLog({}).info(chalk.white("Deleting deployments..."))
+    const servicesLog = log.createLog({}).info(styles.accent("Deleting deployments..."))
     const deploys = graph.getDeploys({ names })
 
     const tasks = deploys.map((action) => {
diff --git a/core/src/server/instance-manager.ts b/core/src/server/instance-manager.ts
index 4e9b463551..04739885e6 100644
--- a/core/src/server/instance-manager.ts
+++ b/core/src/server/instance-manager.ts
@@ -7,7 +7,6 @@
  */
 
 import AsyncLock from "async-lock"
-import chalk from "chalk"
 import type { AutocompleteSuggestion } from "../cli/autocomplete.js"
 import { Autocompleter } from "../cli/autocomplete.js"
 import { parseCliVarFlags } from "../cli/helpers.js"
@@ -40,6 +39,7 @@ import {
 } from "./commands.js"
 import type { GardenInstanceKeyParams } from "./helpers.js"
 import { getGardenInstanceKey } from "./helpers.js"
+import { styles } from "../logger/styles.js"
 
 interface InstanceContext {
   garden: Garden
@@ -244,8 +244,8 @@ export class GardenInstanceManager {
       if (!garden.needsReload()) {
         garden.needsReload(true)
         garden.log.info(
-          chalk.magenta.bold(
-            `${chalk.white("β†’")} Config change detected. Project will be reloaded when the next command is run.`
+          styles.highlightSecondary.bold(
+            `${styles.accent("β†’")} Config change detected. Project will be reloaded when the next command is run.`
           )
         )
       }
diff --git a/core/src/server/server.ts b/core/src/server/server.ts
index 30f8f37638..5fd86ddd17 100644
--- a/core/src/server/server.ts
+++ b/core/src/server/server.ts
@@ -8,7 +8,6 @@
 
 import type { Server } from "http"
 
-import chalk from "chalk"
 import Koa from "koa"
 import Router from "koa-router"
 import websockify from "koa-websocket"
@@ -51,6 +50,7 @@ import { defaultServerPort } from "../commands/serve.js"
 
 import type PTY from "@homebridge/node-pty-prebuilt-multiarch"
 import pty from "@homebridge/node-pty-prebuilt-multiarch"
+import { styles } from "../logger/styles.js"
 
 const skipLogsForCommands = ["autocomplete"]
 
@@ -237,7 +237,9 @@ export class GardenServer extends EventEmitter {
         }
       } while (!serverStarted)
     }
-    this.log.info(chalk.white(`Garden server has successfully started at port ${chalk.bold(this.port)}.\n`))
+    this.log.info(
+      styles.accent(`Garden server has successfully started at port ${styles.bold(this.port.toString())}.\n`)
+    )
 
     const processRecord = await this.globalConfigStore.get("activeProcesses", String(process.pid))
 
@@ -262,7 +264,7 @@ export class GardenServer extends EventEmitter {
 
   showUrl(url?: string) {
     if (this.statusLog) {
-      this.statusLog.info("🌻 " + chalk.cyan("Garden server running at ") + chalk.blueBright(url || this.getUrl()))
+      this.statusLog.info("🌻 " + styles.highlight("Garden server running at ") + styles.link(url || this.getUrl()))
     }
   }
 
@@ -607,7 +609,7 @@ export class GardenServer extends EventEmitter {
             if (exitCode !== 0) {
               websocket.send(msg + "\r\n")
             } else {
-              websocket.send(chalk.green("\r\n\r\nDone!\r\n"))
+              websocket.send(styles.success("\r\n\r\nDone!\r\n"))
             }
             // We use 4700 + exitCode because the websocket close code must be a number between 4000 and 4999
             websocket.close(4700 + exitCode, msg)
@@ -726,7 +728,7 @@ export class GardenServer extends EventEmitter {
               fixLevel: internal ? LogLevel.debug : undefined,
             })
 
-        const cmdNameStr = chalk.bold.white(command.getFullName() + (internal ? ` (internal)` : ""))
+        const cmdNameStr = styles.accent.bold(command.getFullName() + (internal ? ` (internal)` : ""))
         const commandSessionId = requestId
 
         if (skipAnalyticsForCommands.includes(command.getFullName())) {
@@ -811,19 +813,19 @@ export class GardenServer extends EventEmitter {
             )
 
             if (errors?.length && requestLog) {
-              requestLog.error(chalk.red(`Command ${cmdNameStr} failed with errors:`))
+              requestLog.error(`Command ${cmdNameStr} failed with errors:`)
               for (const error of errors) {
                 requestLog.error({ error })
               }
             } else {
-              requestLog?.success(chalk.green(`Command ${cmdNameStr} completed successfully`))
+              requestLog?.success(`Command ${cmdNameStr} completed successfully`)
             }
             delete this.activePersistentRequests[requestId]
           })
           .catch((error) => {
             send("error", { message: error.message, requestId })
             requestLog?.error({
-              msg: chalk.red(`Command ${cmdNameStr} failed with errors:`),
+              msg: `Command ${cmdNameStr} failed with errors:`,
               error: toGardenError(error),
             })
             delete this.activePersistentRequests[requestId]
diff --git a/core/src/tasks/build.ts b/core/src/tasks/build.ts
index 9bc625906d..46f11a1ac7 100644
--- a/core/src/tasks/build.ts
+++ b/core/src/tasks/build.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type { BaseActionTaskParams, ActionTaskProcessParams, ActionTaskStatusParams } from "../tasks/base.js"
 import { ExecuteActionTask, emitGetStatusEvents, emitProcessingEvents } from "../tasks/base.js"
 import { Profile } from "../util/profiling.js"
@@ -127,7 +126,7 @@ export class BuildTask extends ExecuteActionTask<BuildAction, BuildStatus> {
       })
     })
 
-    log.verbose(chalk.green(`Done syncing sources ${renderDuration(log.getDuration(1))}`))
+    log.verbose(`Done syncing sources ${renderDuration(log.getDuration(1))}`)
 
     await wrapActiveSpan("syncDependencyProducts", async () => {
       await this.garden.buildStaging.syncDependencyProducts(action, log)
diff --git a/core/src/tasks/deploy.ts b/core/src/tasks/deploy.ts
index 9a80b27b80..0dc99ff344 100644
--- a/core/src/tasks/deploy.ts
+++ b/core/src/tasks/deploy.ts
@@ -6,8 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
-
 import type { BaseActionTaskParams, BaseTask, ActionTaskProcessParams, ActionTaskStatusParams } from "./base.js"
 import { ExecuteActionTask, emitGetStatusEvents, emitProcessingEvents } from "./base.js"
 import { getLinkUrl } from "../types/service.js"
@@ -18,6 +16,7 @@ import { displayState, resolvedActionToExecuted } from "../actions/helpers.js"
 import type { PluginEventBroker } from "../plugin-context.js"
 import type { ActionLog } from "../logger/log-entry.js"
 import { OtelTraced } from "../util/open-telemetry/decorators.js"
+import { styles } from "../logger/styles.js"
 
 export interface DeployTaskParams extends BaseActionTaskParams<DeployAction> {
   events?: PluginEventBroker
@@ -26,7 +25,7 @@ export interface DeployTaskParams extends BaseActionTaskParams<DeployAction> {
 
 function printIngresses(status: DeployStatus, log: ActionLog) {
   for (const ingress of status.detail?.ingresses || []) {
-    log.info(chalk.gray("Ingress: ") + chalk.underline.gray(getLinkUrl(ingress)))
+    log.info(`Ingress: ${styles.link(getLinkUrl(ingress))}`)
   }
 }
 
@@ -85,7 +84,7 @@ export class DeployTask extends ExecuteActionTask<DeployAction, DeployStatus> {
 
     if (!statusOnly && !this.force) {
       if (status.state === "ready") {
-        log.info("Already deployed")
+        log.success({ msg: `Already deployed`, showDuration: false })
         printIngresses(status, log)
       } else {
         const state = status.detail?.state || displayState(status.state)
@@ -146,7 +145,7 @@ export class DeployTask extends ExecuteActionTask<DeployAction, DeployStatus> {
 
     // Start syncing, if requested
     if (this.startSync && action.mode() === "sync") {
-      log.info(chalk.gray("Starting sync"))
+      log.info(styles.primary("Starting sync"))
       await router.deploy.startSync({ log, graph: this.graph, action: executedAction })
     }
 
diff --git a/core/src/template-string/template-string.ts b/core/src/template-string/template-string.ts
index 42b63d6631..58ec61acf1 100644
--- a/core/src/template-string/template-string.ts
+++ b/core/src/template-string/template-string.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type { GardenErrorParams } from "../exceptions.js"
 import { ConfigurationError, GardenError, TemplateStringError } from "../exceptions.js"
 import type {
@@ -41,6 +40,7 @@ import { actionKindsLower } from "../actions/types.js"
 import { deepMap } from "../util/objects.js"
 import type { ConfigSource } from "../config/validation.js"
 import * as parser from "./parser.js"
+import { styles } from "../logger/styles.js"
 
 const missingKeyExceptionType = "template-string-missing-key"
 const passthroughExceptionType = "template-string-passthrough"
@@ -224,7 +224,7 @@ export function resolveTemplateString({
     if (!(err instanceof GardenError)) {
       throw err
     }
-    const prefix = `Invalid template string (${chalk.white(truncate(string, 35).replace(/\n/g, "\\n"))}): `
+    const prefix = `Invalid template string (${styles.accent(truncate(string, 35).replace(/\n/g, "\\n"))}): `
     const message = err.message.startsWith(prefix) ? err.message : prefix + err.message
 
     throw new TemplateStringError({ message, path })
diff --git a/core/src/util/artifacts.ts b/core/src/util/artifacts.ts
index 6e3bdd9531..edb8200bd9 100644
--- a/core/src/util/artifacts.ts
+++ b/core/src/util/artifacts.ts
@@ -11,7 +11,7 @@ import fsExtra from "fs-extra"
 const { readFile, writeFile } = fsExtra
 import type { Log } from "../logger/log-entry.js"
 import type { Garden } from "../garden.js"
-import chalk from "chalk"
+import { styles } from "../logger/styles.js"
 
 const maxArtifactLogLines = 5 // max number of artifacts to list in console after run+test runs
 
@@ -96,10 +96,10 @@ export async function copyArtifacts({
       files = files.slice(0, maxArtifactLogLines)
     }
     for (const file of files) {
-      log.info(chalk.gray(`β†’ Artifact: ${relative(garden.projectRoot, file)}`))
+      log.info(styles.primary(`β†’ Artifact: ${relative(garden.projectRoot, file)}`))
     }
     if (count > maxArtifactLogLines) {
-      log.info(chalk.gray(`β†’ Artifact: … plus ${count - maxArtifactLogLines} more files`))
+      log.info(styles.primary(`β†’ Artifact: … plus ${count - maxArtifactLogLines} more files`))
     }
   }
 
diff --git a/core/src/util/ext-source-util.ts b/core/src/util/ext-source-util.ts
index eb58e9ea20..71fd159656 100644
--- a/core/src/util/ext-source-util.ts
+++ b/core/src/util/ext-source-util.ts
@@ -7,7 +7,6 @@
  */
 
 import { keyBy } from "lodash-es"
-import chalk from "chalk"
 
 import type { LinkedSource } from "../config-store/local.js"
 import { ParameterError } from "../exceptions.js"
@@ -16,6 +15,7 @@ import type { Garden } from "../garden.js"
 import { hashString } from "./util.js"
 import { naturalList, titleize } from "./string.js"
 import { join } from "path"
+import { styles } from "../logger/styles.js"
 
 export type ExternalSourceType = "project" | "module" | "action"
 
@@ -113,7 +113,7 @@ export async function removeLinkedSources({
   for (const name of names) {
     if (!currentNames.includes(name)) {
       const msgType = sourceType === "project" ? "source" : titleize(sourceType)
-      const msg = `${titleize(msgType)} ${chalk.underline(name)} is not linked. Did you mean to unlink a ${msgType}?`
+      const msg = `${titleize(msgType)} ${styles.underline(name)} is not linked. Did you mean to unlink a ${msgType}?`
       throw new ParameterError({
         message: `${msg}${currentNames.length ? ` Currently linked: ${naturalList(currentNames)}` : ""}`,
       })
diff --git a/core/src/util/module-overlap.ts b/core/src/util/module-overlap.ts
index 2a64eb2660..d6904edde4 100644
--- a/core/src/util/module-overlap.ts
+++ b/core/src/util/module-overlap.ts
@@ -10,10 +10,10 @@ import { posix, resolve } from "path"
 import type { GenerateFileSpec, ModuleConfig } from "../config/module.js"
 import pathIsInside from "path-is-inside"
 import { groupBy, intersection } from "lodash-es"
-import chalk from "chalk"
 import { naturalList } from "./string.js"
 import dedent from "dedent"
 import { InternalError } from "../exceptions.js"
+import { styles } from "../logger/styles.js"
 
 export const moduleOverlapTypes = ["path", "generateFiles"] as const
 export type ModuleOverlapType = (typeof moduleOverlapTypes)[number]
@@ -175,21 +175,21 @@ const makePathOverlapError: ModuleOverlapRenderer = (moduleOverlaps: ModuleOverl
   const overlapList = moduleOverlaps.map(({ config, overlaps }) => {
     const formatted = overlaps.map((o) => {
       const detail = o.path === config.path ? "same path" : "nested"
-      return `${chalk.bold(o.name)} (${detail})`
+      return `${styles.bold(o.name)} (${detail})`
     })
-    return `Module ${chalk.bold(config.name)} overlaps with module(s) ${naturalList(formatted)}.`
+    return `Module ${styles.bold(config.name)} overlaps with module(s) ${naturalList(formatted)}.`
   })
-  return chalk.red(dedent`
+  return styles.error(dedent`
       Found multiple enabled modules that share the same garden.yml file or are nested within another:
 
       ${overlapList.join("\n\n")}
 
       If this was intentional, there are two options to resolve this error:
 
-      - You can add ${chalk.bold("include")} and/or ${chalk.bold("exclude")} directives on the affected modules.
+      - You can add ${styles.bold("include")} and/or ${styles.bold("exclude")} directives on the affected modules.
         By explicitly including / excluding files, the modules are actually allowed to overlap in case that is
         what you want.
-      - You can use the ${chalk.bold("disabled")} directive to make sure that only one of the modules is enabled
+      - You can use the ${styles.bold("disabled")} directive to make sure that only one of the modules is enabled
         at any given time. For example, you can make sure that the modules are enabled only in a certain
         environment.
     `)
@@ -198,14 +198,14 @@ const makePathOverlapError: ModuleOverlapRenderer = (moduleOverlaps: ModuleOverl
 const makeGenerateFilesOverlapError: ModuleOverlapRenderer = (moduleOverlaps: ModuleOverlap[]) => {
   const moduleOverlapList = moduleOverlaps.map(({ config, overlaps, generateFilesOverlaps }) => {
     const formatted = overlaps.map((o) => {
-      return `${chalk.bold(o.name)}`
+      return `${styles.bold(o.name)}`
     })
-    return `Module ${chalk.bold(config.name)} overlaps with module(s) ${naturalList(formatted)} in ${naturalList(
+    return `Module ${styles.bold(config.name)} overlaps with module(s) ${naturalList(formatted)} in ${naturalList(
       generateFilesOverlaps || []
     )}.`
   })
-  return chalk.red(dedent`
-      Found multiple enabled modules that share the same value(s) in ${chalk.bold("generateFiles[].targetPath")}:
+  return styles.error(dedent`
+      Found multiple enabled modules that share the same value(s) in ${styles.bold("generateFiles[].targetPath")}:
 
       ${moduleOverlapList.join("\n\n")}
     `)
diff --git a/core/src/util/profiling.ts b/core/src/util/profiling.ts
index 20a4c3b475..27b2f08c11 100644
--- a/core/src/util/profiling.ts
+++ b/core/src/util/profiling.ts
@@ -10,8 +10,8 @@ import { performance } from "perf_hooks"
 import { sum, sortBy } from "lodash-es"
 import { gardenEnv } from "../constants.js"
 import { renderTable, tablePresets } from "./string.js"
-import chalk from "chalk"
 import { isPromise } from "./objects.js"
+import { styles } from "../logger/styles.js"
 
 const skipProfiling = process.env.GARDEN_SKIP_TEST_PROFILING
 
@@ -52,20 +52,20 @@ export class Profiler {
       const split = key.split("#")
 
       if (split.length === 1) {
-        return chalk.greenBright(key)
+        return styles.success(key)
       } else {
-        return chalk.cyan(split[0]) + "#" + chalk.greenBright(split[1])
+        return styles.highlight(split[0]) + "#" + styles.success(split[1])
       }
     }
 
     function formatDuration(duration: number) {
-      return duration.toFixed(2) + chalk.gray(" ms")
+      return duration.toFixed(2) + styles.primary(" ms")
     }
 
     const keys = Object.keys(this.data)
 
     const heading = ["Function/method", "# Invocations", "Total ms", "Avg. ms", "First ms"].map((h) =>
-      chalk.white.underline(h)
+      styles.accent.underline(h)
     )
     const tableData = sortBy(
       keys.map((key) => {
@@ -90,13 +90,13 @@ export class Profiler {
     const totalRows = keys.length
 
     if (totalRows > maxReportRows) {
-      tableData.push([chalk.gray("...")])
+      tableData.push([styles.primary("...")])
     }
 
     const table = renderTable([heading, [], ...tableData], tablePresets["no-borders"])
 
     return `
- ${chalk.white.bold("Profiling data:")}
+ ${styles.accent.bold("Profiling data:")}
  ─────────────────────────────────────────────────────────────────────────────────────────
 ${table}
  ─────────────────────────────────────────────────────────────────────────────────────────
diff --git a/core/src/util/serialization.ts b/core/src/util/serialization.ts
index 5386c7c9c0..4febdc1c47 100644
--- a/core/src/util/serialization.ts
+++ b/core/src/util/serialization.ts
@@ -12,10 +12,9 @@ const { writeFile } = fsExtra
 import type { DumpOptions } from "js-yaml"
 import { dump } from "js-yaml"
 import highlightModule from "cli-highlight"
+import { styles } from "../logger/styles.js"
 const highlight = highlightModule.default
 
-import chalk from "chalk"
-
 export async function dumpYaml(yamlPath: string, data: any) {
   return writeFile(yamlPath, safeDumpYaml(data, { noRefs: true }))
 }
@@ -38,9 +37,9 @@ export function highlightYaml(s: string) {
   return highlight(s, {
     language: "yaml",
     theme: {
-      keyword: chalk.white.italic,
-      literal: chalk.white.italic,
-      string: chalk.white,
+      keyword: styles.accent.italic,
+      literal: styles.accent.italic,
+      string: styles.accent,
     },
   })
 }
diff --git a/core/src/util/testing.ts b/core/src/util/testing.ts
index 1fbf20a119..20b6de3392 100644
--- a/core/src/util/testing.ts
+++ b/core/src/util/testing.ts
@@ -47,7 +47,7 @@ import fsExtra from "fs-extra"
 const { mkdirp, remove } = fsExtra
 import { GlobalConfigStore } from "../config-store/global.js"
 import { isPromise } from "./objects.js"
-import chalk from "chalk"
+import { styles } from "../logger/styles.js"
 
 export class TestError extends GardenError {
   type = "_test"
@@ -428,7 +428,7 @@ export function expectFuzzyMatch(str: string, sample: string | string[]) {
     samplesNonAnsi.forEach((s) => expect(errorMessageNonAnsi.toLowerCase()).to.contain(s.toLowerCase()))
   } catch (err) {
     // eslint-disable-next-line no-console
-    console.log("Error message:\n", chalk.red(errorMessageNonAnsi), "\n")
+    console.log("Error message:\n", styles.error(errorMessageNonAnsi), "\n")
     throw err
   }
 }
diff --git a/core/src/vcs/git.ts b/core/src/vcs/git.ts
index bd4de0e5fa..a14bc6219a 100644
--- a/core/src/vcs/git.ts
+++ b/core/src/vcs/git.ts
@@ -24,7 +24,6 @@ import parseGitConfig from "parse-git-config"
 import type { Profiler } from "../util/profiling.js"
 import { getDefaultProfiler, Profile } from "../util/profiling.js"
 import isGlob from "is-glob"
-import chalk from "chalk"
 import { pMemoizeDecorator } from "../lib/p-memoize.js"
 import AsyncLock from "async-lock"
 import PQueue from "p-queue"
@@ -33,8 +32,9 @@ import split2 from "split2"
 import type { ExecaError } from "execa"
 import { execa } from "execa"
 import hasha from "hasha"
+import { styles } from "../logger/styles.js"
 
-const submoduleErrorSuggestion = `Perhaps you need to run ${chalk.underline(`git submodule update --recursive`)}?`
+const submoduleErrorSuggestion = `Perhaps you need to run ${styles.underline(`git submodule update --recursive`)}?`
 
 interface GitEntry extends VcsFile {
   mode: string
diff --git a/core/src/vcs/vcs.ts b/core/src/vcs/vcs.ts
index 0e85b56978..f8d3161011 100644
--- a/core/src/vcs/vcs.ts
+++ b/core/src/vcs/vcs.ts
@@ -30,11 +30,9 @@ import { validateInstall } from "../util/validateInstall.js"
 import { isActionConfig, getSourceAbsPath } from "../actions/base.js"
 import type { BaseActionConfig } from "../actions/types.js"
 import type { Garden } from "../garden.js"
-import chalk from "chalk"
 import { Profile } from "../util/profiling.js"
 
 import AsyncLock from "async-lock"
-
 const scanLock = new AsyncLock()
 
 export const versionStringPrefix = "v-"
@@ -237,10 +235,10 @@ export abstract class VcsHandler {
           await this.garden?.emitWarning({
             key: `${projectName}-filecount-${config.name}`,
             log,
-            message: chalk.yellow(dedent`
+            message: dedent`
               Large number of files (${files.length}) found in ${description}. You may need to configure file exclusions.
               See ${DOCS_BASE_URL}/using-garden/configuration-overview#including-excluding-files-and-directories for details.
-            `),
+            `,
           })
         }
 
diff --git a/core/test/integ/helpers.ts b/core/test/integ/helpers.ts
index bee4f8057c..4a03388bc2 100644
--- a/core/test/integ/helpers.ts
+++ b/core/test/integ/helpers.ts
@@ -9,9 +9,9 @@
 import { GoogleAuth, Impersonated } from "google-auth-library"
 import { expect } from "chai"
 import { base64, dedent } from "../../src/util/string.js"
-import chalk from "chalk"
 
 import { ArtifactRegistryClient } from "@google-cloud/artifact-registry"
+import { styles } from "../../src/logger/styles.js"
 
 const targetProject = "garden-ci"
 const targetPrincipal = "gar-serviceaccount@garden-ci.iam.gserviceaccount.com"
@@ -49,7 +49,7 @@ export async function getImpersonatedClientForIntegTests(): Promise<Impersonated
         dedent`
         Could not get downscoped token: Not authenticated to gcloud. Please run the following command:
 
-        ${chalk.bold(`$ gcloud auth application-default login --project ${targetProject}`)}
+        ${styles.bold(`$ gcloud auth application-default login --project ${targetProject}`)}
       `
       )
     }
@@ -61,12 +61,12 @@ export async function getImpersonatedClientForIntegTests(): Promise<Impersonated
         Your user might not be allowed to impersonate the service account '${targetPrincipal}'. You need the role iam.serviceAccountTokenCreator in the project ${targetProject}.
 
         The serviceAccountTokenCreator can be assigned like this:
-        ${chalk.bold(
+        ${styles.bold(
           `$ gcloud iam service-accounts add-iam-policy-binding ${targetPrincipal} --member=<yourIdentity> --role=roles/iam.serviceAccountTokenCreator`
         )}
 
         All developers at garden (dev@garden.io) already have this role, so if you are running into this error and you are part of the google group "Developers <dev@garden.io>", please run the following command:
-        ${chalk.bold(`$ gcloud auth application-default login --project ${targetProject}`)}
+        ${styles.bold(`$ gcloud auth application-default login --project ${targetProject}`)}
         `
       )
     }
diff --git a/core/test/unit/src/commands/logs.ts b/core/test/unit/src/commands/logs.ts
index 6fb84fa253..d6f80f7339 100644
--- a/core/test/unit/src/commands/logs.ts
+++ b/core/test/unit/src/commands/logs.ts
@@ -22,7 +22,6 @@ import {
 } from "../../../helpers.js"
 import { DEFAULT_DEPLOY_TIMEOUT_SEC, GardenApiVersion } from "../../../../src/constants.js"
 import { formatForTerminal } from "../../../../src/logger/renderers.js"
-import chalk from "chalk"
 import type { LogEntry } from "../../../../src/logger/log-entry.js"
 import { LogLevel } from "../../../../src/logger/logger.js"
 import type { DeployLogEntry } from "../../../../src/types/service.js"
@@ -33,6 +32,7 @@ import stripAnsi from "strip-ansi"
 import { execDeploySpecSchema } from "../../../../src/plugins/exec/deploy.js"
 import { joi } from "../../../../src/config/common.js"
 import type { ActionTypeHandlerParamsType } from "../../../../src/plugin/handlers/base/base.js"
+import { styles } from "../../../../src/logger/styles.js"
 
 // TODO-G2: rename test cases to match the new graph model semantics
 
@@ -94,7 +94,7 @@ function getLogOutput(garden: TestGarden, msg: string, extraFilter: (e: LogEntry
 describe("LogsCommand", () => {
   let tmpDir: tmp.DirectoryResult
   const timestamp = new Date()
-  const msgColor = chalk.bgRedBright
+  const msgColor = styles.error
   const logMsg = "Yes, this is log"
   const logMsgWithColor = msgColor(logMsg)
 
diff --git a/core/test/unit/src/commands/publish.ts b/core/test/unit/src/commands/publish.ts
index 266917e4e1..91ff5cdbf7 100644
--- a/core/test/unit/src/commands/publish.ts
+++ b/core/test/unit/src/commands/publish.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { it } from "mocha"
 import { expect } from "chai"
 import { PublishCommand } from "../../../../src/commands/publish.js"
@@ -22,6 +21,7 @@ import { PublishTask } from "../../../../src/tasks/publish.js"
 import { joi } from "../../../../src/config/common.js"
 import { execBuildSpecSchema } from "../../../../src/plugins/exec/build.js"
 import type { ActionTypeHandlerParamsType } from "../../../../src/plugin/handlers/base/base.js"
+import { styles } from "../../../../src/logger/styles.js"
 
 const projectRootB = getDataDir("test-project-b")
 
@@ -228,7 +228,7 @@ describe("PublishCommand", () => {
 
     expect(res).to.exist
     expect(res.state).to.equal("unknown")
-    expect(res.detail.message).to.be.equal(chalk.yellow("No publish handler available for type test"))
+    expect(res.detail.message).to.be.equal(styles.warning("No publish handler available for type test"))
   })
 })
 
diff --git a/core/test/unit/src/logger/log-entry.ts b/core/test/unit/src/logger/log-entry.ts
index c0123cb806..14fe3a4f43 100644
--- a/core/test/unit/src/logger/log-entry.ts
+++ b/core/test/unit/src/logger/log-entry.ts
@@ -14,7 +14,7 @@ import { freezeTime } from "../../../helpers.js"
 import type { CoreLog, Log, LogMetadata } from "../../../../src/logger/log-entry.js"
 import { createActionLog } from "../../../../src/logger/log-entry.js"
 import { omit } from "lodash-es"
-import chalk from "chalk"
+import { styles } from "../../../../src/logger/styles.js"
 
 const logger: Logger = getRootLogger()
 
@@ -81,11 +81,11 @@ describe("Log", () => {
     it("should log success message in green color by default", () => {
       const entry = log.success("success").getLatestEntry()
       expect(entry.level).to.eql(LogLevel.info)
-      expect(entry.msg).to.eql(chalk.green("success"))
+      expect(entry.msg).to.eql(styles.success("success"))
     })
     it("should log success message in original color if it has ansi", () => {
-      const entry = log.success(`hello ${chalk.cyan("cyan")}`).getLatestEntry()
-      expect(entry.msg).to.eql(`hello ${chalk.cyan("cyan")}`)
+      const entry = log.success(`hello ${styles.highlight("cyan")}`).getLatestEntry()
+      expect(entry.msg).to.eql(`hello ${styles.highlight("cyan")}`)
     })
     it("should set the symbol to success", () => {
       const entry = log.success("success").getLatestEntry()
diff --git a/core/test/unit/src/logger/logger.ts b/core/test/unit/src/logger/logger.ts
index 00686b2330..5ed3ab34a1 100644
--- a/core/test/unit/src/logger/logger.ts
+++ b/core/test/unit/src/logger/logger.ts
@@ -13,7 +13,7 @@ import type { LogEntryEventPayload } from "../../../../src/cloud/buffered-event-
 import { freezeTime } from "../../../helpers.js"
 import { QuietWriter } from "../../../../src/logger/writers/quiet-writer.js"
 import { ConfigurationError } from "../../../../src/exceptions.js"
-import chalk from "chalk"
+import { styles } from "../../../../src/logger/styles.js"
 
 const logger: Logger = getRootLogger()
 
@@ -60,7 +60,7 @@ describe("Logger", () => {
           message: {
             msg: "hello",
             rawMsg: "hello-browser",
-            error: chalk.red("hello-error"),
+            error: styles.error("hello-error"),
             section: "log-context-name",
             symbol: "success",
             dataFormat: "json",
diff --git a/core/test/unit/src/logger/renderers.ts b/core/test/unit/src/logger/renderers.ts
index 9efdf0d677..3cdcb365e5 100644
--- a/core/test/unit/src/logger/renderers.ts
+++ b/core/test/unit/src/logger/renderers.ts
@@ -12,8 +12,6 @@ import type { Logger } from "../../../../src/logger/logger.js"
 import { getRootLogger } from "../../../../src/logger/logger.js"
 import {
   renderMsg,
-  msgStyle,
-  errorStyle,
   formatForTerminal,
   renderError,
   formatForJson,
@@ -21,7 +19,6 @@ import {
   renderData,
   padSection,
   renderSection,
-  warningStyle,
 } from "../../../../src/logger/renderers.js"
 import { GenericGardenError } from "../../../../src/exceptions.js"
 
@@ -31,8 +28,8 @@ import logSymbols from "log-symbols"
 import stripAnsi from "strip-ansi"
 import { highlightYaml, safeDumpYaml } from "../../../../src/util/serialization.js"
 import { freezeTime } from "../../../helpers.js"
-import chalk from "chalk"
 import format from "date-fns/format/index.js"
+import { styles } from "../../../../src/logger/styles.js"
 
 const logger: Logger = getRootLogger()
 
@@ -44,15 +41,15 @@ describe("renderers", () => {
   describe("renderMsg", () => {
     it("should render the message with the message style", () => {
       const log = logger.createLog().info("hello message")
-      expect(renderMsg(log.entries[0])).to.equal(msgStyle("hello message"))
+      expect(renderMsg(log.entries[0])).to.equal(styles.primary("hello message"))
     })
     it("should render the message with the error style if the entry has error level", () => {
       const log = logger.createLog().error({ msg: "hello error" })
-      expect(renderMsg(log.entries[0])).to.equal(errorStyle("hello error"))
+      expect(renderMsg(log.entries[0])).to.equal(styles.error("hello error"))
     })
     it("should render the message with the warning style if the entry has warning level", () => {
       const log = logger.createLog().warn({ msg: "hello error" })
-      expect(renderMsg(log.entries[0])).to.equal(warningStyle("hello error"))
+      expect(renderMsg(log.entries[0])).to.equal(styles.warning("hello error"))
     })
   })
   describe("renderError", () => {
@@ -118,20 +115,22 @@ describe("renderers", () => {
       const entry = logger.createLog({ name: "foo" }).info("hello world").getLatestEntry()
 
       expect(formatForTerminal(entry, logger)).to.equal(
-        `${logSymbols["info"]} ${renderSection(entry)}${msgStyle("hello world")}\n`
+        `${logSymbols["info"]} ${renderSection(entry)}${styles.primary("hello world")}\n`
       )
     })
     it("should print the log level if it's higher then 'info'", () => {
       const entry = logger.createLog().debug({ msg: "hello world" }).getLatestEntry()
 
-      expect(formatForTerminal(entry, logger)).to.equal(`${chalk.gray("[debug]")} ${msgStyle("hello world")}\n`)
+      expect(formatForTerminal(entry, logger)).to.equal(
+        `${styles.primary("[debug]")} ${styles.primary("hello world")}\n`
+      )
     })
     it("should print the log level if it's higher then 'info' after the section if there is one", () => {
       const entry = logger.createLog({ name: "foo" }).debug("hello world").getLatestEntry()
 
-      const section = `foo ${chalk.gray("[debug]")}`
+      const section = `foo ${styles.primary("[debug]")}`
       expect(formatForTerminal(entry, logger)).to.equal(
-        `${logSymbols["info"]} ${chalk.cyan.italic(padSection(section))} β†’ ${msgStyle("hello world")}\n`
+        `${logSymbols["info"]} ${styles.highlight.italic(padSection(section))} β†’ ${styles.primary("hello world")}\n`
       )
     })
     context("basic", () => {
@@ -143,7 +142,7 @@ describe("renderers", () => {
         const entry = logger.createLog().info("hello world").getLatestEntry()
 
         expect(formatForTerminal(entry, logger)).to.equal(
-          `${chalk.gray(format(now, "HH:mm:ss"))} ${msgStyle("hello world")}\n`
+          `${styles.primary(format(now, "HH:mm:ss"))} ${styles.primary("hello world")}\n`
         )
       })
       after(() => {
diff --git a/core/test/unit/src/logger/writers/file-writer.ts b/core/test/unit/src/logger/writers/file-writer.ts
index 65330bddd5..f93b969ffd 100644
--- a/core/test/unit/src/logger/writers/file-writer.ts
+++ b/core/test/unit/src/logger/writers/file-writer.ts
@@ -7,7 +7,6 @@
  */
 
 import { expect } from "chai"
-import chalk from "chalk"
 import stripAnsi from "strip-ansi"
 import { RuntimeError } from "../../../../../src/exceptions.js"
 
@@ -15,6 +14,7 @@ import type { Logger } from "../../../../../src/logger/logger.js"
 import { getRootLogger, LogLevel } from "../../../../../src/logger/logger.js"
 import { renderError } from "../../../../../src/logger/renderers.js"
 import { render } from "../../../../../src/logger/writers/file-writer.js"
+import { styles } from "../../../../../src/logger/styles.js"
 
 const logger: Logger = getRootLogger()
 
@@ -25,7 +25,7 @@ beforeEach(() => {
 describe("FileWriter", () => {
   describe("render", () => {
     it("should render message without ansi characters", () => {
-      const entry = logger.createLog().info(chalk.red("hello")).getLatestEntry()
+      const entry = logger.createLog().info(styles.error("hello")).getLatestEntry()
       expect(render(LogLevel.info, entry)).to.equal("hello")
     })
     it("should render error object if passed", () => {
diff --git a/plugins/conftest/src/index.ts b/plugins/conftest/src/index.ts
index a6fc863131..0ec9f0758f 100644
--- a/plugins/conftest/src/index.ts
+++ b/plugins/conftest/src/index.ts
@@ -7,7 +7,7 @@
  */
 
 import { resolve, relative } from "path"
-import chalk from "chalk"
+import { styles } from "@garden-io/core/build/src/logger/styles.js"
 import slash from "slash"
 import type { ExecaReturnValue } from "execa"
 import { createGardenPlugin } from "@garden-io/sdk"
@@ -503,7 +503,7 @@ function parseConftestResult(provider: ConftestProvider, log: Log, result: Execa
     const failuresForFilename = failures || []
     for (const failure of failuresForFilename) {
       lines.push(
-        chalk.redBright.bold("FAIL") + chalk.gray(" - ") + chalk.redBright(filename) + chalk.gray(" - ") + failure.msg
+        styles.error.bold("FAIL") + styles.primary(" - ") + styles.error(filename) + styles.primary(" - ") + failure.msg
       )
       countFailures += 1
     }
@@ -511,10 +511,10 @@ function parseConftestResult(provider: ConftestProvider, log: Log, result: Execa
     const warningsForFilename = warnings || []
     for (const warning of warningsForFilename) {
       lines.push(
-        chalk.yellowBright.bold("WARN") +
-          chalk.gray(" - ") +
-          chalk.yellowBright(filename) +
-          chalk.gray(" - ") +
+        styles.warning.bold("WARN") +
+          styles.primary(" - ") +
+          styles.warning(filename) +
+          styles.primary(" - ") +
           warning.msg
       )
 
@@ -539,7 +539,7 @@ function parseConftestResult(provider: ConftestProvider, log: Log, result: Execa
   } else if (countFailures > 0 && threshold !== "none") {
     success = false
   } else if (countWarnings > 0) {
-    log.warn(chalk.yellow(formattedHeader))
+    log.warn(formattedHeader)
   }
 
   if (!success) {
diff --git a/plugins/pulumi/src/commands.ts b/plugins/pulumi/src/commands.ts
index ddc6d61f87..acb13c958d 100644
--- a/plugins/pulumi/src/commands.ts
+++ b/plugins/pulumi/src/commands.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import type {
   ConfigGraph,
   Garden,
@@ -44,6 +43,7 @@ import type { ActionLog, Log } from "@garden-io/core/build/src/logger/log-entry.
 import { createActionLog } from "@garden-io/core/build/src/logger/log-entry.js"
 import { ActionSpecContext } from "@garden-io/core/build/src/config/template-contexts/actions.js"
 import type { ProviderMap } from "@garden-io/core/build/src/config/provider.js"
+import { styles } from "@garden-io/core/build/src/logger/styles.js"
 
 type PulumiBaseParams = Omit<PulumiParams, "action">
 
@@ -149,7 +149,7 @@ const pulumiCommandSpecs: PulumiCommandSpec[] = [
     //   const summaryPath = join(previewDirPath, "plan-summary.json")
     //   await writeJSON(summaryPath, totalSummary, { spaces: 2 })
     //   log.info("")
-    //   log.info(chalk.green(`Wrote plan summary to ${chalk.white(summaryPath)}`))
+    //   log.info(styles.success(`Wrote plan summary to ${styles.accent(summaryPath)}`))
     //   return totalSummary
     // },
   },
@@ -305,7 +305,7 @@ class PulumiPluginCommandTask extends PluginActionTask<PulumiDeploy, PulumiComma
   }
 
   async process({ dependencyResults }: ActionTaskProcessParams<PulumiDeploy, PulumiCommandResult>) {
-    this.log.info(chalk.gray(`Running ${chalk.white(this.commandDescription)}`))
+    this.log.info(styles.primary(`Running ${styles.accent(this.commandDescription)}`))
 
     const params = {
       ...this.pulumiParams,
@@ -335,7 +335,7 @@ export const getPulumiCommands = (): PluginCommand[] => pulumiCommandSpecs.map(m
 
 function makePulumiCommand({ name, commandDescription, beforeFn, runFn, afterFn }: PulumiCommandSpec) {
   const description = commandDescription || `pulumi ${name}`
-  const pulumiCommand = chalk.bold(description)
+  const pulumiCommand = styles.bold(description)
 
   const pulumiCommandOpts = {
     "skip-dependencies": new BooleanParameter({
@@ -358,7 +358,7 @@ function makePulumiCommand({ name, commandDescription, beforeFn, runFn, afterFn
     resolveGraph: true,
 
     title: ({ args }) =>
-      chalk.bold.magenta(`Running ${chalk.white.bold(pulumiCommand)} for actions ${chalk.white.bold(args[0] || "")}`),
+      styles.command(`Running ${styles.accent.bold(pulumiCommand)} for actions ${styles.accent.bold(args[0] || "")}`),
 
     async handler({ garden, ctx, args, log, graph }: PluginCommandParams) {
       const parsed = parsePluginCommandArgs({
diff --git a/plugins/pulumi/src/handlers.ts b/plugins/pulumi/src/handlers.ts
index 3a177ca93d..9bbba1ba61 100644
--- a/plugins/pulumi/src/handlers.ts
+++ b/plugins/pulumi/src/handlers.ts
@@ -22,7 +22,6 @@ import {
 } from "./helpers.js"
 import type { PulumiDeploy } from "./action.js"
 import type { PulumiProvider } from "./provider.js"
-import chalk from "chalk"
 import type { DeployActionHandlers } from "@garden-io/core/build/src/plugin/action-types.js"
 import type { DeployState } from "@garden-io/core/build/src/types/service.js"
 import { deployStateToActionState } from "@garden-io/core/build/src/plugin/handlers/Deploy/get-status.js"
@@ -124,7 +123,7 @@ export const deployPulumi: DeployActionHandlers<PulumiDeploy>["deploy"] = async
 
 export const deletePulumiDeploy: DeployActionHandlers<PulumiDeploy>["delete"] = async ({ ctx, log, action }) => {
   if (!action.getSpec("allowDestroy")) {
-    log.warn(chalk.yellow(`${action.longDescription()} has allowDestroy = false. Skipping destroy.`))
+    log.warn(`${action.longDescription()} has allowDestroy = false. Skipping destroy.`)
     return {
       state: deployStateToActionState("outdated"),
       outputs: {},
diff --git a/plugins/pulumi/src/helpers.ts b/plugins/pulumi/src/helpers.ts
index fcb1675541..2783bffd5f 100644
--- a/plugins/pulumi/src/helpers.ts
+++ b/plugins/pulumi/src/helpers.ts
@@ -9,7 +9,7 @@
 import { countBy, flatten, isEmpty, uniq } from "lodash-es"
 import { load } from "js-yaml"
 import stripAnsi from "strip-ansi"
-import chalk from "chalk"
+import { styles } from "@garden-io/core/build/src/logger/styles.js"
 import { merge } from "json-merge-patch"
 import { basename, dirname, extname, join, resolve } from "path"
 import fsExtra from "fs-extra"
@@ -142,7 +142,7 @@ export async function previewStack(
       previewUrl = urlMatch ? urlMatch[1] : null
       log.info(res.stdout)
     } else {
-      log.info(`No resources were changed in the generated plan for ${chalk.cyan(action.key())}.`)
+      log.info(`No resources were changed in the generated plan for ${styles.highlight(action.key())}.`)
     }
   } else {
     log.verbose(res.stdout)
@@ -387,7 +387,7 @@ export async function cancelUpdate({ action, ctx, provider, log }: PulumiParams)
   log.info(res.stdout)
 
   if (res.exitCode !== 0) {
-    log.warn(chalk.yellow(`pulumi cancel failed:\n${res.stderr}`))
+    log.warn(`pulumi cancel failed:\n${res.stderr}`)
     return {
       state: "failed",
       outputs: {},
diff --git a/plugins/terraform/src/commands.ts b/plugins/terraform/src/commands.ts
index e89e27bb5a..7b1b7ffe8c 100644
--- a/plugins/terraform/src/commands.ts
+++ b/plugins/terraform/src/commands.ts
@@ -6,7 +6,6 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
-import chalk from "chalk"
 import { terraform } from "./cli.js"
 import type { TerraformProvider } from "./provider.js"
 import { ConfigurationError, ParameterError } from "@garden-io/sdk/build/src/exceptions.js"
@@ -17,26 +16,27 @@ import fsExtra from "fs-extra"
 const { remove } = fsExtra
 import { getProviderStatusCachePath } from "@garden-io/core/build/src/tasks/resolve-provider.js"
 import type { TerraformDeploy } from "./action.js"
+import { styles } from "@garden-io/core/build/src/logger/styles.js"
 
 const commandsToWrap = ["apply", "plan", "destroy"]
-const initCommand = chalk.bold("terraform init")
+const initCommand = styles.bold("terraform init")
 
 export const getTerraformCommands = (): PluginCommand[] =>
   commandsToWrap.flatMap((commandName) => [makeRootCommand(commandName), makeActionCommand(commandName)])
 
 function makeRootCommand(commandName: string): PluginCommand {
-  const terraformCommand = chalk.bold("terraform " + commandName)
+  const terraformCommand = styles.bold("terraform " + commandName)
 
   return {
     name: commandName + "-root",
     description: `Runs ${terraformCommand} for the provider root stack, with the provider variables automatically configured as inputs. Positional arguments are passed to the command. If necessary, ${initCommand} is run first.`,
-    title: chalk.bold.magenta(`Running ${chalk.white.bold(terraformCommand)} for project root stack`),
+    title: styles.command(`Running ${styles.accent.bold(terraformCommand)} for project root stack`),
     async handler({ ctx, args, log }: PluginCommandParams) {
       const provider = ctx.provider as TerraformProvider
 
       if (!provider.config.initRoot) {
         throw new ConfigurationError({
-          message: `terraform provider does not have an ${chalk.underline(
+          message: `terraform provider does not have an ${styles.underline(
             "initRoot"
           )} configured in the provider section of the project configuration`,
         })
@@ -72,7 +72,7 @@ function makeRootCommand(commandName: string): PluginCommand {
 }
 
 function makeActionCommand(commandName: string): PluginCommand {
-  const terraformCommand = chalk.bold("terraform " + commandName)
+  const terraformCommand = styles.bold("terraform " + commandName)
 
   return {
     name: commandName + "-action",
@@ -80,8 +80,8 @@ function makeActionCommand(commandName: string): PluginCommand {
     resolveGraph: true,
 
     title: ({ args }) =>
-      chalk.bold.magenta(
-        `Running ${chalk.white.bold(terraformCommand)} for the Deploy action ${chalk.white.bold(args[0] || "")}`
+      styles.command(
+        `Running ${styles.accent.bold(terraformCommand)} for the Deploy action ${styles.accent.bold(args[0] || "")}`
       ),
 
     async handler({ garden, ctx, args, log, graph }) {
@@ -122,7 +122,7 @@ function findAction(graph: ConfigGraph, name: string): TerraformDeploy {
 
   if (!action.isCompatible("terraform")) {
     throw new ParameterError({
-      message: chalk.red(`Action ${chalk.white(name)} is not a terraform action (got ${action.type}).`),
+      message: styles.error(`Action ${styles.accent(name)} is not a terraform action (got ${action.type}).`),
     })
   }
 
diff --git a/plugins/terraform/src/handlers.ts b/plugins/terraform/src/handlers.ts
index 0e0295ea8b..206205746c 100644
--- a/plugins/terraform/src/handlers.ts
+++ b/plugins/terraform/src/handlers.ts
@@ -15,7 +15,7 @@ import type { DeployActionHandler } from "@garden-io/core/build/src/plugin/actio
 import type { DeployState } from "@garden-io/core/build/src/types/service.js"
 import { deployStateToActionState } from "@garden-io/core/build/src/plugin/handlers/Deploy/get-status.js"
 import type { TerraformDeploy, TerraformDeploySpec } from "./action.js"
-import chalk from "chalk"
+import { styles } from "@garden-io/core/build/src/logger/styles.js"
 
 export const getTerraformStatus: DeployActionHandler<"getStatus", TerraformDeploy> = async ({ ctx, log, action }) => {
   const provider = ctx.provider as TerraformProvider
@@ -57,7 +57,7 @@ export const deployTerraform: DeployActionHandler<"deploy", TerraformDeploy> = a
   } else {
     const templateKey = `\${runtime.services.${action.name}.outputs.*}`
     log.warn(
-      chalk.yellow(
+      styles.warning(
         deline`
         Stack is out-of-date but autoApply is set to false, so it will not be applied automatically. If any newly added
         stack outputs are referenced via ${templateKey} template strings and are missing,
diff --git a/plugins/terraform/src/helpers.ts b/plugins/terraform/src/helpers.ts
index 9ee8fc4a9c..dbabd1609a 100644
--- a/plugins/terraform/src/helpers.ts
+++ b/plugins/terraform/src/helpers.ts
@@ -16,10 +16,10 @@ import { terraform } from "./cli.js"
 import type { TerraformProvider } from "./provider.js"
 import fsExtra from "fs-extra"
 const { writeFile } = fsExtra
-import chalk from "chalk"
 import type { PrimitiveMap } from "@garden-io/core/build/src/config/common.js"
 import { joi, joiStringMap } from "@garden-io/core/build/src/config/common.js"
 import split2 from "split2"
+import { styles } from "@garden-io/core/build/src/logger/styles.js"
 
 export const variablesSchema = () => joiStringMap(joi.any())
 
@@ -166,7 +166,7 @@ export async function getStackStatus(params: TerraformParamsWithVariables): Prom
 
   if (plan.exitCode === 0) {
     // Stack is up-to-date
-    statusLog.success(chalk.green("Stack up-to-date"))
+    statusLog.success(styles.success("Stack up-to-date"))
     return "up-to-date"
   } else if (plan.exitCode === 1) {
     // Error from terraform. This can, for example, happen if variables are missing or there are errors in the tf files.
@@ -206,7 +206,7 @@ export async function applyStack(params: TerraformParamsWithVariables) {
   }
 
   logStream.on("data", (line: Buffer) => {
-    statusLine.info(chalk.gray("β†’ " + line.toString()))
+    statusLine.info(styles.primary("β†’ " + line.toString()))
   })
 
   await new Promise<void>((_resolve, reject) => {
diff --git a/plugins/terraform/src/init.ts b/plugins/terraform/src/init.ts
index 1290161a33..51aea33cfd 100644
--- a/plugins/terraform/src/init.ts
+++ b/plugins/terraform/src/init.ts
@@ -8,10 +8,10 @@
 
 import type { TerraformProvider } from "./provider.js"
 import { applyStack, getRoot, getStackStatus, getTfOutputs, prepareVariables, setWorkspace } from "./helpers.js"
-import chalk from "chalk"
 import { deline } from "@garden-io/sdk/build/src/util/string.js"
 import type { ProviderHandlers } from "@garden-io/sdk/build/src/types.js"
 import { terraform } from "./cli.js"
+import { styles } from "@garden-io/core/build/src/logger/styles.js"
 
 export const getEnvironmentStatus: ProviderHandlers["getEnvironmentStatus"] = async ({ ctx, log }) => {
   const provider = ctx.provider as TerraformProvider
@@ -36,8 +36,8 @@ export const getEnvironmentStatus: ProviderHandlers["getEnvironmentStatus"] = as
       return { ready: false, outputs: {} }
     } else {
       log.warn(deline`
-        Terraform stack is not up-to-date and ${chalk.underline("autoApply")} is not enabled. Please run
-        ${chalk.white.bold("garden plugins terraform apply-root")} to make sure the stack is in the intended state.
+        Terraform stack is not up-to-date and ${styles.underline("autoApply")} is not enabled. Please run
+        ${styles.accent.bold("garden plugins terraform apply-root")} to make sure the stack is in the intended state.
       `)
       const outputs = await getTfOutputs({ log, ctx, provider, root })
       // Make sure the status is not cached when the stack is not up-to-date