Skip to content

Commit ea409cc

Browse files
authored
Merge pull request #16 from jniu-atlas/issue/DEPLOY-2389-Add-Tracking-Env-Var-To-Forge-Skill-all
Stamp ATL_FORGE_ATTRIBUTION_SKILL_NAME on Forge CLI invocations
2 parents 8c1c248 + 1aae672 commit ea409cc

14 files changed

Lines changed: 354 additions & 12 deletions

File tree

‎skills/forge-app-builder/SKILL.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ namespace: cloud
2424
5. **Always ask the user to choose** when multiple options exist (developer spaces, sites) — never pick on their behalf
2525
6. **Always ask the user for their Atlassian site URL during installation** — never try to discover it from other apps, environment variables, or any other source
2626
7. **Always run the deploy script for deploy and install** — do not give the user only manual `forge deploy` / `forge install` commands as the primary outcome; run `scripts.deploy_forge_app.py` yourself
27+
8. **Set `ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-app-builder` on `forge` commands run for this skill** — prefix `forge` invocations with this env var: ones you run in the shell (e.g. `forge developer-spaces list`, `forge lint`) **and the interactive `forge create` command you hand the user as a fallback**. The bundled scripts set it automatically; other commands shown in this skill omit it for brevity — add it when you run them. The only exclusions are `forge login` and `forge tunnel` (user-run auth / live-dev commands).
2728

2829
## MCP Server Prerequisites
2930

@@ -70,7 +71,7 @@ Install in order: Node.js first (required for npm), then Forge CLI, then login.
7071
### Step 1: Discover Developer Spaces
7172

7273
```bash
73-
forge developer-spaces list --json
74+
ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-app-builder forge developer-spaces list --json
7475
```
7576

7677
### Step 2: Ask User to Choose Developer Space
@@ -188,7 +189,7 @@ Example response when it fails:
188189
```
189190
forge create needs an interactive terminal. Please run:
190191
191-
forge create --template jira-dashboard-gadget my-app-name
192+
ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-app-builder forge create --template jira-dashboard-gadget my-app-name
192193
193194
Once created, let me know and I'll help customize it.
194195
```

‎skills/forge-app-builder/scripts/create_forge_app.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@
1515

1616
# Relative imports — scripts/ is a package; run as python -m scripts.create_forge_app from skill dir
1717
from . import list_templates as list_templates_module
18+
from .forge_env import forge_env
19+
20+
# Environment for every Forge CLI invocation this script spawns.
21+
_FORGE_ENV = forge_env("forge-app-builder")
1822

1923
def validate_prerequisites():
2024
"""Check if Forge CLI and Node.js are available"""
2125
try:
22-
subprocess.run(['forge', '--version'], capture_output=True, check=True)
23-
subprocess.run(['node', '-v'], capture_output=True, check=True)
26+
subprocess.run(['forge', '--version'], capture_output=True, check=True, env=_FORGE_ENV)
27+
subprocess.run(['node', '-v'], capture_output=True, check=True, env=_FORGE_ENV)
2428
return True
2529
except (subprocess.CalledProcessError, FileNotFoundError):
2630
return False
@@ -59,7 +63,7 @@ def discover_dev_spaces():
5963
try:
6064
result = subprocess.run(
6165
['forge', 'developer-spaces', 'list', '--json'],
62-
capture_output=True, text=True, timeout=30,
66+
capture_output=True, text=True, timeout=30, env=_FORGE_ENV,
6367
)
6468
if result.returncode != 0:
6569
print(f"⚠️ forge developer-spaces list failed: {result.stderr.strip()}")
@@ -139,7 +143,7 @@ def create_app(template, app_name, output_dir=None, dev_space_id=None):
139143
print(f"\n📦 Creating Forge app: {app_name}")
140144
print(f"📋 Template: {template}")
141145
print(f"📂 Location: {cwd}")
142-
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
146+
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=_FORGE_ENV)
143147

144148
if result.returncode != 0:
145149
stderr = result.stderr.strip()
@@ -191,7 +195,7 @@ def main():
191195
print("\n📋 Please create a developer space at:")
192196
print(" https://developer.atlassian.com/console/")
193197
print("\nThen run this script again, or use 'forge create' interactively:")
194-
print(f" forge create --template {args.template} {args.name}")
198+
print(f" ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-app-builder forge create --template {args.template} {args.name}")
195199
sys.exit(1)
196200

197201
# Step 1b: Ask user which dev space to use

‎skills/forge-app-builder/scripts/deploy_forge_app.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
import time
2323
from pathlib import Path
2424

25+
from .forge_env import forge_env
26+
27+
# Environment for every Forge CLI invocation this script spawns.
28+
_FORGE_ENV = forge_env("forge-app-builder")
29+
2530

2631
class Colors:
2732
HEADER = '\033[95m'
@@ -66,7 +71,8 @@ def run_command(cmd, cwd=None, capture_output=True, check=True):
6671
cwd=cwd,
6772
capture_output=capture_output,
6873
text=True,
69-
check=check
74+
check=check,
75+
env=_FORGE_ENV,
7076
)
7177
return result
7278
except subprocess.CalledProcessError as e:
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Environment helper for spawning the Forge CLI.
2+
3+
The Forge CLI reads a reserved ``ATL_FORGE_ATTRIBUTION_*`` namespace from
4+
its environment and forwards every key in it to the backend. This helper
5+
returns an environment mapping with the skill identifier stamped in, so
6+
that every ``forge`` command a skill spawns carries it.
7+
8+
The same namespace is open-ended, so the helper supports arbitrary
9+
wildcard fields beyond the skill name:
10+
11+
* ``extra=`` stamps additional keys programmatically, e.g.
12+
``forge_env("forge-app-builder", extra={"run_id": "abc"})`` →
13+
``ATL_FORGE_ATTRIBUTION_RUN_ID=abc``.
14+
* any ``ATL_FORGE_ATTRIBUTION_*`` var already present in the environment
15+
(e.g. set by the agent host) is preserved and forwarded as-is.
16+
17+
Values the helper stamps follow the CLI's contract — short tokens
18+
matching ``[A-Za-z0-9._-]`` and at most 128 characters; values that
19+
don't match are dropped silently rather than raising.
20+
"""
21+
22+
import os
23+
import re
24+
25+
_ATTRIBUTION_PREFIX = "ATL_FORGE_ATTRIBUTION_"
26+
_VALUE_RE = re.compile(r"^[A-Za-z0-9._-]+$")
27+
_MAX_LEN = 128
28+
29+
30+
def _is_valid_value(value):
31+
"""True if ``value`` satisfies the Forge CLI value contract."""
32+
return (
33+
isinstance(value, str)
34+
and 0 < len(value) <= _MAX_LEN
35+
and _VALUE_RE.match(value) is not None
36+
)
37+
38+
39+
def forge_env(skill_name, extra=None, base=None):
40+
"""Return an environment dict for spawning the Forge CLI.
41+
42+
Starts from a copy of the current process environment (or ``base`` if
43+
provided) and stamps ``ATL_FORGE_ATTRIBUTION_SKILL_NAME=<skill_name>``.
44+
45+
``extra`` may supply additional ``ATL_FORGE_ATTRIBUTION_*`` fields as a
46+
mapping of unprefixed keys to values (e.g. ``{"SESSION_ID": "abc"}`` →
47+
``ATL_FORGE_ATTRIBUTION_SESSION_ID=abc``). Keys are upper-cased and
48+
prefixed; entries whose value fails validation are skipped.
49+
50+
Any ``ATL_FORGE_ATTRIBUTION_*`` vars already in the source environment
51+
are left untouched, so wildcard fields set by the caller's environment
52+
pass through to the CLI unchanged.
53+
"""
54+
# Copying the source env preserves ambient ATL_FORGE_ATTRIBUTION_* vars.
55+
env = dict(os.environ if base is None else base)
56+
57+
fields = {"SKILL_NAME": skill_name}
58+
if extra:
59+
fields.update(extra)
60+
61+
for key, value in fields.items():
62+
if not _is_valid_value(value):
63+
continue
64+
env[_ATTRIBUTION_PREFIX + key.upper()] = value
65+
66+
return env

‎skills/forge-app-builder/tests/test_create_forge_app.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,24 @@ def test_runs_from_parent_directory(self, mock_prereqs, mock_validate, mock_run)
203203
call_kwargs = mock_run.call_args[1]
204204
self.assertEqual(call_kwargs.get("cwd"), "/parent")
205205

206+
@patch("scripts.create_forge_app.subprocess.run")
207+
@patch("scripts.create_forge_app.validate_template", return_value=(True, None))
208+
@patch("scripts.create_forge_app.validate_prerequisites", return_value=True)
209+
def test_stamps_skill_name_env_var(self, mock_prereqs, mock_validate, mock_run):
210+
"""forge create must carry the skill-name attribution env var."""
211+
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
212+
213+
with patch("scripts.create_forge_app.os.path.isdir", return_value=True), \
214+
patch("scripts.create_forge_app.os.path.exists", return_value=False):
215+
cfa.create_app(
216+
"jira-issue-panel-ui-kit", "my-app",
217+
output_dir="/tmp", dev_space_id="abc-123",
218+
)
219+
220+
env = mock_run.call_args[1].get("env")
221+
self.assertIsNotNone(env)
222+
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
223+
206224

207225
if __name__ == "__main__":
208226
unittest.main()

‎skills/forge-app-builder/tests/test_deploy_forge_app.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,15 @@ def test_raises_on_failure(self, mock_run):
176176
with self.assertRaises(subprocess.CalledProcessError):
177177
dfa.run_command("bad-cmd")
178178

179+
@patch("scripts.deploy_forge_app.subprocess.run")
180+
def test_stamps_skill_name_env_var(self, mock_run):
181+
"""Every command run by the deploy script carries the attribution env var."""
182+
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
183+
dfa.run_command("forge deploy")
184+
env = mock_run.call_args[1].get("env")
185+
self.assertIsNotNone(env)
186+
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
187+
179188

180189
if __name__ == "__main__":
181190
unittest.main()
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Tests for scripts/forge_env.py"""
2+
import unittest
3+
4+
from scripts.forge_env import forge_env
5+
6+
7+
class TestForgeEnv(unittest.TestCase):
8+
9+
def test_stamps_skill_name(self):
10+
env = forge_env("forge-app-builder", base={})
11+
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
12+
13+
def test_preserves_base_environment(self):
14+
env = forge_env("forge-app-builder", base={"PATH": "/usr/bin", "HOME": "/home/x"})
15+
self.assertEqual(env["PATH"], "/usr/bin")
16+
self.assertEqual(env["HOME"], "/home/x")
17+
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
18+
19+
def test_defaults_to_current_process_environment(self):
20+
# Without an explicit base, the current environment is copied (and not mutated).
21+
import os
22+
env = forge_env("forge-app-builder")
23+
self.assertIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", env)
24+
self.assertNotIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", os.environ)
25+
26+
def test_extra_keys_are_upper_cased_and_prefixed(self):
27+
env = forge_env("forge-app-builder", extra={"session_id": "abc123"}, base={})
28+
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SESSION_ID"], "abc123")
29+
30+
def test_multiple_wildcard_extras_are_all_stamped(self):
31+
env = forge_env(
32+
"forge-app-builder",
33+
extra={"run_id": "r1", "session_id": "s1"},
34+
base={},
35+
)
36+
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_RUN_ID"], "r1")
37+
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SESSION_ID"], "s1")
38+
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
39+
40+
def test_ambient_wildcard_vars_pass_through(self):
41+
# Wildcard vars the agent host already set must reach the CLI untouched.
42+
env = forge_env(
43+
"forge-app-builder",
44+
base={"ATL_FORGE_ATTRIBUTION_RUN_ID": "host-run-42", "PATH": "/usr/bin"},
45+
)
46+
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_RUN_ID"], "host-run-42")
47+
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
48+
49+
def test_invalid_extra_value_is_dropped(self):
50+
env = forge_env("forge-app-builder", extra={"run_id": "bad value!"}, base={})
51+
self.assertNotIn("ATL_FORGE_ATTRIBUTION_RUN_ID", env)
52+
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
53+
54+
def test_over_length_value_is_dropped(self):
55+
env = forge_env("x" * 129, base={})
56+
self.assertNotIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", env)
57+
58+
def test_invalid_charset_value_is_dropped(self):
59+
env = forge_env("bad name!", base={})
60+
self.assertNotIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", env)
61+
62+
def test_empty_value_is_dropped(self):
63+
env = forge_env("", base={})
64+
self.assertNotIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", env)
65+
66+
def test_non_string_value_is_dropped(self):
67+
env = forge_env(None, base={}) # type: ignore[arg-type]
68+
self.assertNotIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", env)
69+
70+
71+
if __name__ == "__main__":
72+
unittest.main()

‎skills/forge-connector/SKILL.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Builds a `graph:connector` Forge app that ingests external data into Atlassian's
3939
10. **`function` declarations belong under `modules`** — In `manifest.yml`, `function:` is a key under `modules:`, not a top-level key. Placing it at the top level causes a lint error.
4040
11. **`formConfiguration` uses `form` array with `type: header`** — Do NOT use `fields:` or `beforeYouBegin:`. The correct format uses `form: [{ key, type: header, title, description, properties: [...] }]`.
4141
12. **Scopes are `read/write/delete:object:jira`** — Use `read:object:jira`, `write:object:jira`, `delete:object:jira`. The scopes `read:graph:teamwork` and `write:graph:teamwork` are invalid and will fail `forge lint`.
42+
13. **Set `ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-connector` on `forge` commands run for this skill** — prefix `forge` invocations with this env var: ones you run in the shell (e.g. `forge lint`, `forge logs`, `forge deploy`) **and the interactive `forge create` command you hand the user as a fallback**. The bundled scripts set it automatically; other commands shown in this skill omit it for brevity — add it when you run them. The only exclusions are `forge login` and `forge tunnel` (user-run auth / live-dev commands).
4243

4344
## MCP Prerequisites
4445

@@ -72,7 +73,7 @@ Tell the user to run `forge login` in their terminal if not authenticated.
7273
```
7374
Tell the user:
7475
cd <parent-directory>
75-
forge create --template blank <app-name>
76+
ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-connector forge create --template blank <app-name>
7677
7778
When prompted, select a Developer Space and let it complete.
7879
Come back when done.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Environment helper for spawning the Forge CLI.
2+
3+
The Forge CLI reads a reserved ``ATL_FORGE_ATTRIBUTION_*`` namespace from
4+
its environment and forwards every key in it to the backend. This helper
5+
returns an environment mapping with the skill identifier stamped in, so
6+
that every ``forge`` command a skill spawns carries it.
7+
8+
The same namespace is open-ended, so the helper supports arbitrary
9+
wildcard fields beyond the skill name:
10+
11+
* ``extra=`` stamps additional keys programmatically, e.g.
12+
``forge_env("forge-connector", extra={"run_id": "abc"})`` →
13+
``ATL_FORGE_ATTRIBUTION_RUN_ID=abc``.
14+
* any ``ATL_FORGE_ATTRIBUTION_*`` var already present in the environment
15+
(e.g. set by the agent host) is preserved and forwarded as-is.
16+
17+
Values the helper stamps follow the CLI's contract — short tokens
18+
matching ``[A-Za-z0-9._-]`` and at most 128 characters; values that
19+
don't match are dropped silently rather than raising.
20+
"""
21+
22+
import os
23+
import re
24+
25+
_ATTRIBUTION_PREFIX = "ATL_FORGE_ATTRIBUTION_"
26+
_VALUE_RE = re.compile(r"^[A-Za-z0-9._-]+$")
27+
_MAX_LEN = 128
28+
29+
30+
def _is_valid_value(value):
31+
"""True if ``value`` satisfies the Forge CLI value contract."""
32+
return (
33+
isinstance(value, str)
34+
and 0 < len(value) <= _MAX_LEN
35+
and _VALUE_RE.match(value) is not None
36+
)
37+
38+
39+
def forge_env(skill_name, extra=None, base=None):
40+
"""Return an environment dict for spawning the Forge CLI.
41+
42+
Starts from a copy of the current process environment (or ``base`` if
43+
provided) and stamps ``ATL_FORGE_ATTRIBUTION_SKILL_NAME=<skill_name>``.
44+
45+
``extra`` may supply additional ``ATL_FORGE_ATTRIBUTION_*`` fields as a
46+
mapping of unprefixed keys to values (e.g. ``{"SESSION_ID": "abc"}`` →
47+
``ATL_FORGE_ATTRIBUTION_SESSION_ID=abc``). Keys are upper-cased and
48+
prefixed; entries whose value fails validation are skipped.
49+
50+
Any ``ATL_FORGE_ATTRIBUTION_*`` vars already in the source environment
51+
are left untouched, so wildcard fields set by the caller's environment
52+
pass through to the CLI unchanged.
53+
"""
54+
# Copying the source env preserves ambient ATL_FORGE_ATTRIBUTION_* vars.
55+
env = dict(os.environ if base is None else base)
56+
57+
fields = {"SKILL_NAME": skill_name}
58+
if extra:
59+
fields.update(extra)
60+
61+
for key, value in fields.items():
62+
if not _is_valid_value(value):
63+
continue
64+
env[_ATTRIBUTION_PREFIX + key.upper()] = value
65+
66+
return env

0 commit comments

Comments
 (0)