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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- `apm install` now automatically discovers and deploys local `.apm/` primitives (skills, instructions, agents, prompts, hooks, commands) to target directories, with local content taking priority over dependencies on collision (#626, #644)
- Add `temp-dir` configuration key (`apm config set temp-dir PATH`) to override the system temporary directory, resolving `[WinError 5] Access is denied` in corporate Windows environments (#629)

### Fixed

Expand Down
27 changes: 27 additions & 0 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,7 @@ apm config
- Global configuration
- APM CLI version
- `auto-integrate` setting
- `temp-dir` setting (when configured)

**Examples:**
```bash
Expand All @@ -1363,6 +1364,7 @@ apm config get [KEY]
**Arguments:**
- `KEY` (optional) - Configuration key to retrieve. Supported keys:
- `auto-integrate` - Whether to automatically integrate `.prompt.md` files into AGENTS.md
- `temp-dir` - Custom temporary directory for clone/download operations

If `KEY` is omitted, displays all configuration values.

Expand All @@ -1386,6 +1388,7 @@ apm config set KEY VALUE
**Arguments:**
- `KEY` - Configuration key to set. Supported keys:
- `auto-integrate` - Enable/disable automatic integration of `.prompt.md` files
- `temp-dir` - Set a custom temporary directory path
- `VALUE` - Value to set. For boolean keys, use: `true`, `false`, `yes`, `no`, `1`, `0`

**Configuration Keys:**
Expand All @@ -1411,6 +1414,30 @@ apm config set auto-integrate yes
apm config set auto-integrate 1
```

**`temp-dir`** - Override the system temporary directory
- **Type:** String (directory path)
- **Default:** System temp directory (not stored)
- **Description:** Set a custom temporary directory for clone and download operations. Useful in corporate Windows environments where endpoint security software restricts access to `%TEMP%`, causing `[WinError 5] Access is denied`.
- **Resolution order:** `APM_TEMP_DIR` environment variable > `temp_dir` in `~/.apm/config.json` > system default.
- **Use Cases:**
- Set when the default system temp directory is restricted or unavailable
- Use the `APM_TEMP_DIR` environment variable for CI pipelines or per-session overrides

**Examples:**
```bash
# Set a custom temp directory (Windows)
apm config set temp-dir C:\apm-temp

# Set a custom temp directory (macOS/Linux)
apm config set temp-dir /tmp/apm-work

# Check the current temp-dir setting
apm config get temp-dir

# Or use the environment variable instead
export APM_TEMP_DIR=/tmp/apm-work
```

## Runtime Management (Experimental)

### `apm runtime` (Experimental) - Manage AI runtimes
Expand Down
4 changes: 2 additions & 2 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,6 @@
| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm config` | Show current configuration | -- |
| `apm config get [KEY]` | Get a config value | -- |
| `apm config set KEY VALUE` | Set a config value | -- |
| `apm config get [KEY]` | Get a config value (`auto-integrate`, `temp-dir`) | -- |
| `apm config set KEY VALUE` | Set a config value (`auto-integrate`, `temp-dir`) | -- |
| `apm update` | Update APM itself | `--check` only check |
3 changes: 2 additions & 1 deletion src/apm_cli/bundle/unpacker.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ def unpack_bundle(
# 1. If archive, extract to temp dir
cleanup_temp = False
if bundle_path.is_file() and bundle_path.name.endswith(".tar.gz"):
temp_dir = Path(tempfile.mkdtemp(prefix="apm-unpack-"))
from ..config import get_apm_temp_dir
temp_dir = Path(tempfile.mkdtemp(prefix="apm-unpack-", dir=get_apm_temp_dir()))
cleanup_temp = True
try:
with tarfile.open(bundle_path, "r:gz") as tar:
Expand Down
36 changes: 32 additions & 4 deletions src/apm_cli/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ def config(ctx):

config_table.add_row("Global", "APM CLI Version", get_version())

from ..config import get_temp_dir as _get_temp_dir

_temp_dir_val = _get_temp_dir()
if _temp_dir_val:
config_table.add_row("", "Temp Directory", _temp_dir_val)

console.print(config_table)

except (ImportError, NameError):
Expand All @@ -105,6 +111,12 @@ def config(ctx):
click.echo(f"\n{HIGHLIGHT}Global:{RESET}")
click.echo(f" APM CLI Version: {get_version()}")

from ..config import get_temp_dir as _get_temp_dir_fb

_temp_dir_fb = _get_temp_dir_fb()
if _temp_dir_fb:
click.echo(f" Temp Directory: {_temp_dir_fb}")


@config.command(help="Set a configuration value")
@click.argument("key")
Expand All @@ -116,7 +128,7 @@ def set(key, value):
apm config set auto-integrate false
apm config set auto-integrate true
"""
from ..config import set_auto_integrate
from ..config import set_auto_integrate, set_temp_dir

logger = CommandLogger("config set")
if key == "auto-integrate":
Expand All @@ -129,9 +141,17 @@ def set(key, value):
else:
logger.error(f"Invalid value '{value}'. Use 'true' or 'false'.")
sys.exit(1)
elif key == "temp-dir":
try:
set_temp_dir(value)
from ..config import get_temp_dir
logger.success(f"Temporary directory set to: {get_temp_dir()}")
except ValueError as exc:
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
logger.error(str(exc))
sys.exit(1)
else:
logger.error(f"Unknown configuration key: '{key}'")
logger.progress("Valid keys: auto-integrate")
logger.progress("Valid keys: auto-integrate, temp-dir")
logger.progress(
"This error may indicate a bug in command routing. Please report this issue."
)
Expand All @@ -147,16 +167,22 @@ def get(key):
apm config get auto-integrate
apm config get
"""
from ..config import get_auto_integrate
from ..config import get_auto_integrate, get_temp_dir

logger = CommandLogger("config get")
if key:
if key == "auto-integrate":
value = get_auto_integrate()
click.echo(f"auto-integrate: {value}")
elif key == "temp-dir":
value = get_temp_dir()
if value is None:
click.echo("temp-dir: Not set (using system default)")
else:
click.echo(f"temp-dir: {value}")
else:
logger.error(f"Unknown configuration key: '{key}'")
logger.progress("Valid keys: auto-integrate")
logger.progress("Valid keys: auto-integrate, temp-dir")
logger.progress(
"This error may indicate a bug in command routing. Please report this issue."
)
Expand All @@ -167,3 +193,5 @@ def get(key):
# have not been written yet (e.g. auto_integrate on a fresh install).
logger.progress("APM Configuration:")
click.echo(f" auto-integrate: {get_auto_integrate()}")
temp_dir = get_temp_dir()
click.echo(f" temp-dir: {temp_dir if temp_dir is not None else 'Not set (using system default)'}")
4 changes: 3 additions & 1 deletion src/apm_cli/commands/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,10 @@ def update(check):
response.raise_for_status()

# Create temporary file for install script
from ..config import get_apm_temp_dir
with tempfile.NamedTemporaryFile(
mode="w", suffix=_get_update_installer_suffix(), delete=False
mode="w", suffix=_get_update_installer_suffix(), delete=False,
dir=get_apm_temp_dir()
) as f:
temp_script = f.name
f.write(response.text)
Expand Down
54 changes: 54 additions & 0 deletions src/apm_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,57 @@ def set_auto_integrate(enabled: bool) -> None:
enabled: Whether to enable auto-integration.
"""
update_config({"auto_integrate": enabled})


def get_temp_dir() -> Optional[str]:
"""Get the configured temporary directory.

Returns:
The stored temp_dir config value, or None if not set.
"""
return get_config().get("temp_dir")


def set_temp_dir(path: str) -> None:
"""Set the temporary directory after validating it exists and is writable.

The path is normalised (``~`` expansion + absolute) before validation and
storage so that relative or home-relative paths work predictably.

Args:
path: Filesystem path to use as temporary directory.

Raises:
ValueError: If the path does not exist, is not a directory, or is not
writable.
"""
resolved = os.path.abspath(os.path.expanduser(path))
if not os.path.exists(resolved):
raise ValueError(f"Directory does not exist: {resolved}")
if not os.path.isdir(resolved):
raise ValueError(f"Path is not a directory: {resolved}")
if not os.access(resolved, os.W_OK):
raise ValueError(f"Directory is not writable: {resolved}")
update_config({"temp_dir": resolved})


def get_apm_temp_dir() -> Optional[str]:
"""Return the effective temporary directory for APM operations.

Resolution order:
1. ``APM_TEMP_DIR`` environment variable (escape-hatch override)
2. ``temp_dir`` value from ``~/.apm/config.json``
3. ``None`` (caller falls back to the system default)

Empty or whitespace-only values are treated as unset and skipped.

Returns:
Directory path string, or None when the system default should be used.
"""
env_val = os.environ.get("APM_TEMP_DIR", "").strip()
if env_val:
return env_val
config_val = (get_temp_dir() or "").strip()
if config_val:
return config_val
return None
40 changes: 35 additions & 5 deletions src/apm_cli/deps/github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ def _setup_git_environment(self) -> Dict[str, Any]:
if sys.platform == 'win32':
# 'NUL' fails on some Windows git versions; use an empty temp file.
import tempfile
empty_cfg = os.path.join(tempfile.gettempdir(), '.apm_empty_gitconfig')
from ..config import get_apm_temp_dir
temp_base = get_apm_temp_dir() or tempfile.gettempdir()
empty_cfg = os.path.join(temp_base, '.apm_empty_gitconfig')
with open(empty_cfg, 'w') as f:
pass
env['GIT_CONFIG_GLOBAL'] = empty_cfg
Expand Down Expand Up @@ -954,7 +956,8 @@ def resolve_git_reference(self, repo_ref: Union[str, "DependencyReference"]) ->
# Create a temporary directory for Git operations
temp_dir = None
try:
temp_dir = Path(tempfile.mkdtemp())
from ..config import get_apm_temp_dir
temp_dir = Path(tempfile.mkdtemp(dir=get_apm_temp_dir()))

if is_likely_commit:
# For commit SHAs, clone full repository first, then checkout the commit
Expand Down Expand Up @@ -1775,8 +1778,10 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat
# tempfile.TemporaryDirectory().__exit__ calls shutil.rmtree without our
# retry logic, which raises WinError 32 when git processes still hold
# handles at the end of the with-block.
temp_dir = tempfile.mkdtemp()
from ..config import get_apm_temp_dir
temp_dir = None
try:
temp_dir = tempfile.mkdtemp(dir=get_apm_temp_dir())
# Sparse checkout always targets "repo/". If it fails we clone into
# "repo_clone/" so we never have to rmtree a directory that may still
# have live git handles from the failed subprocess.
Expand Down Expand Up @@ -1886,8 +1891,32 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat
if progress_obj and progress_task_id is not None:
progress_obj.update(progress_task_id, completed=90, total=100)

except PermissionError as exc:
exc_path = getattr(exc, 'filename', None)
# If temp_dir wasn't created (mkdtemp failed) or the error is within
# the temp tree, this is likely a restricted temp directory issue.
if temp_dir is None or (exc_path and str(exc_path).startswith(str(temp_dir))):
raise RuntimeError(
"Access denied in temporary directory"
+ (f" '{temp_dir}'" if temp_dir else "")
+ ". Corporate security may restrict this path. "
"Fix: apm config set temp-dir <WRITABLE_PATH>"
) from None
raise
except OSError as exc:
if getattr(exc, 'errno', None) == 13 or getattr(exc, 'winerror', None) == 5:
exc_path = getattr(exc, 'filename', None)
if temp_dir is None or (exc_path and str(exc_path).startswith(str(temp_dir))):
raise RuntimeError(
"Access denied in temporary directory"
+ (f" '{temp_dir}'" if temp_dir else "")
+ ". Corporate security may restrict this path. "
"Fix: apm config set temp-dir <WRITABLE_PATH>"
) from None
raise
finally:
_rmtree(temp_dir)
if temp_dir:
_rmtree(temp_dir)

# Validate the extracted package (after temp dir is cleaned up)
validation_result = validate_apm_package(target_path)
Expand Down Expand Up @@ -1938,6 +1967,7 @@ def _download_subdirectory_from_artifactory(
) -> PackageInfo:
"""Download an archive from Artifactory and extract a subdirectory."""
import tempfile
from ..config import get_apm_temp_dir
ref = dep_ref.reference or "main"
subdir_path = dep_ref.virtual_path
repo_parts = dep_ref.repo_url.split('/')
Expand All @@ -1947,7 +1977,7 @@ def _download_subdirectory_from_artifactory(
if progress_obj and progress_task_id is not None:
progress_obj.update(progress_task_id, completed=10, total=100)

with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(dir=get_apm_temp_dir()) as temp_dir:
temp_path = Path(temp_dir) / "full_pkg"
self._download_artifactory_archive(host, prefix, owner, repo, ref, temp_path, scheme=scheme)
if progress_obj and progress_task_id is not None:
Expand Down
3 changes: 2 additions & 1 deletion src/apm_cli/runtime/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ def run_embedded_script(self, script_content: str, common_content: str,
"""Execute an embedded setup script with common utilities."""
script_args = script_args or []

with tempfile.TemporaryDirectory() as temp_dir:
from ..config import get_apm_temp_dir
with tempfile.TemporaryDirectory(dir=get_apm_temp_dir()) as temp_dir:
temp_path = Path(temp_dir)

if self._is_windows:
Expand Down
Loading
Loading